-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add GitHub App integration foundation #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| # GitHub App integration | ||
|
|
||
| FlowForge includes a small GitHub integration foundation for verified webhooks and GitHub REST API calls made with a GitHub App installation token. | ||
|
|
||
| ## Security model | ||
|
|
||
| - Verify every webhook against the **raw request body** with `verifyGitHubWebhookSignature` before parsing JSON or starting a workflow. | ||
| - Keep the GitHub App private key, webhook secret, and installation tokens outside source control. | ||
| - Supply short-lived installation tokens to `GitHubApiClient`; the client never writes tokens into request bodies or error messages. | ||
| - The API client accepts HTTPS endpoints only and constrains requests to relative GitHub API paths. | ||
| - Grant the GitHub App only the repository permissions required by the workflows you enable. | ||
|
|
||
| ## Register the GitHub App | ||
|
|
||
| Create a GitHub App named **Karzoun FlowForge Bridge** (or another unique name) in GitHub Developer settings. | ||
|
|
||
| Recommended starting configuration: | ||
|
|
||
| - Homepage URL: `https://github.com/mkarson1997/karzoun-flowforge` | ||
| - Webhook: enabled | ||
| - Webhook URL: your public FlowForge bridge endpoint, for example `https://YOUR-HOST/webhooks/github` | ||
| - Webhook secret: a randomly generated secret stored only in your deployment secret manager | ||
| - Installation scope: only the repositories where FlowForge workflows should run | ||
|
|
||
| Start with least privilege. A repository-health or workflow-trigger integration can usually begin with: | ||
|
|
||
| - Metadata: Read-only (GitHub requires metadata access) | ||
| - Actions: Read-only when reading workflow-run state | ||
| - Contents: Read-only when workflow definitions or repository files are read | ||
| - Issues: Read-only if issue events become triggers | ||
| - Pull requests: Read-only if pull request events become triggers | ||
|
|
||
| Subscribe only to events you actually handle, such as `pull_request`, `issues`, or `workflow_run`. | ||
|
|
||
| ## Webhook verification | ||
|
|
||
| ```ts | ||
| import { verifyGitHubWebhookSignature } from "@karzoun/flowforge"; | ||
|
|
||
| const valid = verifyGitHubWebhookSignature( | ||
| process.env.GITHUB_WEBHOOK_SECRET ?? "", | ||
| rawRequestBody, | ||
| request.headers["x-hub-signature-256"], | ||
| ); | ||
|
|
||
| if (!valid) { | ||
| throw new Error("Invalid GitHub webhook signature"); | ||
| } | ||
| ``` | ||
|
|
||
| Do not reconstruct or re-stringify JSON before verification. GitHub signs the exact raw bytes delivered to the webhook endpoint. | ||
|
|
||
| ## GitHub REST API | ||
|
|
||
| Obtain a short-lived installation token through GitHub App authentication, then inject it into the client: | ||
|
|
||
| ```ts | ||
| import { GitHubApiClient } from "@karzoun/flowforge"; | ||
|
|
||
| const github = new GitHubApiClient({ | ||
| token: process.env.GITHUB_INSTALLATION_TOKEN ?? "", | ||
| }); | ||
|
|
||
| const repository = await github.getRepository("mkarson1997", "karzoun-flowforge"); | ||
| const runs = await github.listWorkflowRuns("mkarson1997", "karzoun-flowforge", repository.default_branch); | ||
| ``` | ||
|
|
||
| A completed FlowForge execution can also emit an explicit `repository_dispatch` event when the installed app has the required permission: | ||
|
|
||
| ```ts | ||
| await github.createRepositoryDispatch( | ||
| "mkarson1997", | ||
| "karzoun-flowforge", | ||
| "flowforge.completed", | ||
| { executionId: "run-123" }, | ||
| ); | ||
| ``` | ||
|
|
||
| ## Production boundary | ||
|
|
||
| This module deliberately does not generate GitHub App JWTs or persist private keys. App authentication and token minting belong at the deployment boundary or in a dedicated secrets-aware adapter. That keeps FlowForge's core runtime free of long-lived GitHub credentials. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| import { createHmac, timingSafeEqual } from "node:crypto"; | ||
|
|
||
| export interface GitHubApiClientOptions { | ||
| token: string; | ||
| apiBaseUrl?: string; | ||
| userAgent?: string; | ||
| fetchImpl?: typeof fetch; | ||
| } | ||
|
|
||
| export interface GitHubRepositorySummary { | ||
| id: number; | ||
| name: string; | ||
| full_name: string; | ||
| private: boolean; | ||
| default_branch: string; | ||
| html_url: string; | ||
| } | ||
|
|
||
| export interface GitHubWorkflowRunSummary { | ||
| id: number; | ||
| name: string; | ||
| status: string | null; | ||
| conclusion: string | null; | ||
| html_url: string; | ||
| head_sha: string; | ||
| } | ||
|
|
||
| export interface GitHubWorkflowRunsResponse { | ||
| total_count: number; | ||
| workflow_runs: GitHubWorkflowRunSummary[]; | ||
| } | ||
|
|
||
| export class GitHubApiError extends Error { | ||
| constructor( | ||
| readonly status: number, | ||
| readonly method: string, | ||
| readonly path: string, | ||
| ) { | ||
| super(`GitHub API request failed: ${method} ${path} returned HTTP ${status}`); | ||
| this.name = "GitHubApiError"; | ||
| } | ||
| } | ||
|
|
||
| export function verifyGitHubWebhookSignature( | ||
| secret: string, | ||
| rawBody: string | Uint8Array, | ||
| signatureHeader: string | null | undefined, | ||
| ): boolean { | ||
| if (secret.length === 0 || !signatureHeader?.startsWith("sha256=")) { | ||
| return false; | ||
| } | ||
|
|
||
| const suppliedHex = signatureHeader.slice("sha256=".length); | ||
| if (!/^[0-9a-f]{64}$/i.test(suppliedHex)) { | ||
| return false; | ||
| } | ||
|
|
||
| const expected = createHmac("sha256", secret).update(rawBody).digest(); | ||
| const supplied = Buffer.from(suppliedHex, "hex"); | ||
| return expected.length === supplied.length && timingSafeEqual(expected, supplied); | ||
| } | ||
|
|
||
| export class GitHubApiClient { | ||
| private readonly token: string; | ||
| private readonly apiBaseUrl: URL; | ||
| private readonly userAgent: string; | ||
| private readonly fetchImpl: typeof fetch; | ||
|
|
||
| constructor(options: GitHubApiClientOptions) { | ||
| if (options.token.trim().length === 0) { | ||
| throw new Error("GitHub installation token must not be empty"); | ||
| } | ||
|
|
||
| const apiBaseUrl = new URL(options.apiBaseUrl ?? "https://api.github.com/"); | ||
| if (apiBaseUrl.protocol !== "https:") { | ||
| throw new Error("GitHub API base URL must use HTTPS"); | ||
| } | ||
| if (apiBaseUrl.username || apiBaseUrl.password || apiBaseUrl.search || apiBaseUrl.hash) { | ||
| throw new Error("GitHub API base URL must not contain credentials, a query, or a fragment"); | ||
| } | ||
| if (!apiBaseUrl.pathname.endsWith("/")) { | ||
| apiBaseUrl.pathname += "/"; | ||
| } | ||
|
|
||
| this.token = options.token; | ||
| this.apiBaseUrl = apiBaseUrl; | ||
| this.userAgent = options.userAgent ?? "karzoun-flowforge"; | ||
| this.fetchImpl = options.fetchImpl ?? globalThis.fetch; | ||
| } | ||
|
|
||
| async getRepository(owner: string, repo: string): Promise<GitHubRepositorySummary> { | ||
| return this.request<GitHubRepositorySummary>( | ||
| "GET", | ||
| `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, | ||
| ); | ||
| } | ||
|
|
||
| async listWorkflowRuns( | ||
| owner: string, | ||
| repo: string, | ||
| branch?: string, | ||
| ): Promise<GitHubWorkflowRunsResponse> { | ||
| const query = branch ? `?branch=${encodeURIComponent(branch)}` : ""; | ||
| return this.request<GitHubWorkflowRunsResponse>( | ||
| "GET", | ||
| `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs${query}`, | ||
| ); | ||
| } | ||
|
|
||
| async createRepositoryDispatch( | ||
| owner: string, | ||
| repo: string, | ||
| eventType: string, | ||
| clientPayload: Record<string, unknown> = {}, | ||
| ): Promise<void> { | ||
| if (!/^[A-Za-z0-9._-]{1,100}$/.test(eventType)) { | ||
| throw new Error("GitHub repository_dispatch event type is invalid"); | ||
|
Comment on lines
+116
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a workflow listens for a valid custom event type containing characters outside this private allowlist, such as Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| await this.request<void>( | ||
| "POST", | ||
| `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/dispatches`, | ||
| { | ||
| event_type: eventType, | ||
| client_payload: clientPayload, | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| private buildUrl(path: string): URL { | ||
| if (!path.startsWith("/") || path.startsWith("//") || path.includes("..")) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For repositories whose valid GitHub name contains consecutive periods (for example Useful? React with 👍 / 👎. |
||
| throw new Error("GitHub API path must be an absolute API path without traversal"); | ||
| } | ||
|
|
||
| const url = new URL(path.slice(1), this.apiBaseUrl); | ||
| if (url.origin !== this.apiBaseUrl.origin) { | ||
| throw new Error("GitHub API request escaped the configured origin"); | ||
| } | ||
| return url; | ||
| } | ||
|
|
||
| private async request<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> { | ||
| const headers: Record<string, string> = { | ||
| accept: "application/vnd.github+json", | ||
| authorization: `Bearer ${this.token}`, | ||
| "user-agent": this.userAgent, | ||
| "x-github-api-version": "2022-11-28", | ||
| }; | ||
|
|
||
| const init: RequestInit = { method, headers }; | ||
| if (body !== undefined) { | ||
| headers["content-type"] = "application/json"; | ||
| init.body = JSON.stringify(body); | ||
| } | ||
|
|
||
| const response = await this.fetchImpl(this.buildUrl(path), init); | ||
| if (!response.ok) { | ||
| throw new GitHubApiError(response.status, method, path); | ||
| } | ||
| if (response.status === 204) { | ||
| return undefined as T; | ||
| } | ||
|
|
||
| return (await response.json()) as T; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { createHmac } from "node:crypto"; | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| GitHubApiClient, | ||
| GitHubApiError, | ||
| verifyGitHubWebhookSignature, | ||
| } from "../src/github-app.js"; | ||
|
|
||
| describe("GitHub webhook verification", () => { | ||
| it("accepts a valid sha256 signature", () => { | ||
| const secret = "test-webhook-secret"; | ||
| const body = Buffer.from('{"action":"opened"}', "utf8"); | ||
| const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; | ||
|
|
||
| expect(verifyGitHubWebhookSignature(secret, body, signature)).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects malformed or incorrect signatures", () => { | ||
| const body = '{"action":"opened"}'; | ||
| expect(verifyGitHubWebhookSignature("secret", body, "sha1=abc")).toBe(false); | ||
| expect(verifyGitHubWebhookSignature("secret", body, "sha256=xyz")).toBe(false); | ||
| expect(verifyGitHubWebhookSignature("secret", body, `sha256=${"0".repeat(64)}`)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("GitHubApiClient", () => { | ||
| it("uses installation-token authentication and safely encodes repository names", async () => { | ||
| let requestUrl = ""; | ||
| let requestInit: RequestInit | undefined; | ||
| const fetchImpl: typeof fetch = async (input, init) => { | ||
| requestUrl = String(input); | ||
| requestInit = init; | ||
| return new Response( | ||
| JSON.stringify({ | ||
| id: 42, | ||
| name: "repo/name", | ||
| full_name: "owner space/repo/name", | ||
| private: false, | ||
| default_branch: "main", | ||
| html_url: "https://github.com/owner-space/repo-name", | ||
| }), | ||
| { status: 200, headers: { "content-type": "application/json" } }, | ||
| ); | ||
| }; | ||
|
|
||
| const client = new GitHubApiClient({ token: "installation-token", fetchImpl }); | ||
| const repository = await client.getRepository("owner space", "repo/name"); | ||
|
|
||
| expect(repository.id).toBe(42); | ||
| expect(requestUrl).toBe("https://api.github.com/repos/owner%20space/repo%2Fname"); | ||
| const headers = new Headers(requestInit?.headers); | ||
| expect(headers.get("authorization")).toBe("Bearer installation-token"); | ||
| expect(headers.get("x-github-api-version")).toBe("2022-11-28"); | ||
| }); | ||
|
|
||
| it("creates repository_dispatch events without exposing the token in the body", async () => { | ||
| let body = ""; | ||
| const fetchImpl: typeof fetch = async (_input, init) => { | ||
| body = String(init?.body ?? ""); | ||
| return new Response(null, { status: 204 }); | ||
| }; | ||
|
|
||
| const client = new GitHubApiClient({ token: "secret-token", fetchImpl }); | ||
| await client.createRepositoryDispatch("owner", "repo", "flowforge.completed", { | ||
| executionId: "run-123", | ||
| }); | ||
|
|
||
| expect(JSON.parse(body)).toEqual({ | ||
| event_type: "flowforge.completed", | ||
| client_payload: { executionId: "run-123" }, | ||
| }); | ||
| expect(body).not.toContain("secret-token"); | ||
| }); | ||
|
|
||
| it("surfaces status and endpoint without copying an API response body into errors", async () => { | ||
| const fetchImpl: typeof fetch = async () => | ||
| new Response('{"message":"private diagnostic"}', { status: 403 }); | ||
| const client = new GitHubApiClient({ token: "installation-token", fetchImpl }); | ||
|
|
||
| const request = client.getRepository("owner", "repo"); | ||
| await expect(request).rejects.toBeInstanceOf(GitHubApiError); | ||
| await expect(request).rejects.not.toThrow(/private diagnostic/); | ||
| }); | ||
|
|
||
| it("requires HTTPS for GitHub API endpoints", () => { | ||
| expect( | ||
| () => new GitHubApiClient({ token: "token", apiBaseUrl: "http://example.test/api/v3/" }), | ||
| ).toThrow(/HTTPS/); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (bug_risk): Passing a Node.js
IncomingMessageheader directly, as shown in the documentation, suppliesstring | string[] | undefined; when GitHub's signature header is represented as an array,signatureHeader.startsWith(...)raisesTypeErrorinstead of rejecting the request. The documented usage also fails TypeScript checking because the function does not acceptstring[].Triggers: When the webhook framework exposes duplicate or multi-valued
x-hub-signature-256headers.Suggested fix: Accept only a string at runtime and return
falsefor arrays, or have the integration adapter explicitly reject and normalize multi-valued headers before calling the verifier.