From e5b4f751b33dfd9bf0d9ce22dc90bb5070f7c119 Mon Sep 17 00:00:00 2001 From: Jeremy Williams Date: Mon, 19 Oct 2026 20:08:28 -0700 Subject: [PATCH] feat: GitHub App installation auth for GitHub sources (#457) Adds GitHub App installation as an authentication option for GitHub sources, with installation token refresh for Gitea 1.27+ pull mirrors, credential redaction, and the account filters applied to installation listings. --- docs/SOURCE_PROVIDERS.md | 6 + src/components/config/ConfigTabs.tsx | 8 +- src/components/config/SourcesCard.tsx | 75 +++++++++++-- src/hooks/useConfigStatus.test.ts | 13 +++ src/hooks/useConfigStatus.ts | 26 ++--- src/lib/gitea-enhanced.test.ts | 87 +++++++++++++++ src/lib/gitea-enhanced.ts | 29 ++++- src/lib/gitea-mirror-credentials.test.ts | 27 +++++ src/lib/gitea-mirror-credentials.ts | 93 ++++++++++++++++ src/lib/gitea-org-mirror-destination.test.ts | 2 + src/lib/gitea.ts | 33 +++++- src/lib/github-app-client.ts | 26 +++++ src/lib/github-app-credentials.test.ts | 31 ++++++ src/lib/github-app-credentials.ts | 80 ++++++++++++++ src/lib/github.ts | 103 +++++++++++------- src/lib/organization-sync.ts | 5 + src/lib/push-engine/mirror.ts | 12 +- src/lib/recovery.ts | 9 +- src/lib/scheduler-service.ts | 14 ++- .../source-providers/github-source.test.ts | 66 +++++++++++ src/lib/source-providers/github-source.ts | 42 ++++++- src/lib/sources-api.ts | 21 +++- src/lib/sources.ts | 39 +++++++ src/pages/api/config/index.ts | 3 +- src/pages/api/dashboard/index.test.ts | 79 ++++++++++++++ src/pages/api/dashboard/index.ts | 16 ++- src/pages/api/github/starred-lists.ts | 4 + src/pages/api/github/test-connection.ts | 23 +++- src/pages/api/job/mirror-org.ts | 5 + src/pages/api/job/mirror-repo.ts | 5 + src/pages/api/job/retry-repo.ts | 5 + src/pages/api/rate-limit/index.ts | 10 +- src/pages/api/sources/[id].test.ts | 61 +++++++++++ src/pages/api/sources/[id].ts | 50 ++++++++- src/pages/api/sources/index.test.ts | 26 +++++ src/pages/api/sources/index.ts | 28 ++++- src/types/config.ts | 3 + 37 files changed, 1065 insertions(+), 100 deletions(-) create mode 100644 src/hooks/useConfigStatus.test.ts create mode 100644 src/lib/gitea-mirror-credentials.test.ts create mode 100644 src/lib/gitea-mirror-credentials.ts create mode 100644 src/lib/github-app-client.ts create mode 100644 src/lib/github-app-credentials.test.ts create mode 100644 src/lib/github-app-credentials.ts create mode 100644 src/pages/api/dashboard/index.test.ts diff --git a/docs/SOURCE_PROVIDERS.md b/docs/SOURCE_PROVIDERS.md index 4ffadd8..0b3fbd4 100644 --- a/docs/SOURCE_PROVIDERS.md +++ b/docs/SOURCE_PROVIDERS.md @@ -22,6 +22,12 @@ Picking GitLab or Gitea/Forgejo shows an **Instance URL** field. Leave it empty The **Test** button on the card checks the token against the selected host. +For a GitHub source, **Authentication** can also be set to **GitHub App installation**. Create a GitHub App with repository **Contents** and **Metadata** read permissions; grant **Issues** and **Pull requests** read permissions if mirroring that metadata. Install it on the repositories to back up, enter the installation account or organization under **Username**, then enter its App ID, installation ID and PEM private key in the Sources card. The private key is encrypted at rest and is not returned by the source API. The App sees only repositories selected for its installation; it cannot discover a user's starred repositories or star lists. PAT authentication remains available for those workflows. + +App installation tokens expire after an hour, so Gitea Mirror stores a fresh one on the mirror before each of its own scheduled or manual syncs and turns off Gitea's independent pull schedule for App-backed repositories. Keep the Gitea Mirror scheduler enabled for ongoing App-backed mirrors. Existing PAT mirrors continue using their usual interval. + +Replacing a mirror's credentials needs Gitea 1.27 or later as the destination. Gitea 1.26 and earlier, and Forgejo, ignore those fields, so App-backed repositories are refused there with an error instead of being mirrored and failing once the first token expires. Use a personal access token for those destinations. GitHub and GitLab destinations clone with a fresh token on every push and work with any version. + ## What is mirrored The mirror itself is done by Gitea's pull mirror, which treats every source as a plain git remote. The differences are in what the source API offers. diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index fd4634a..9f40a66 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -133,7 +133,13 @@ export function ConfigTabs() { // githubConfig check only covers APIs that predate multi-source support. const isSourceConnected = (): boolean => { if (sources.length > 0) { - return sources.some((source) => Boolean(source.token && source.token.trim())); + return sources.some((source) => { + if (!source.enabled) return false; + if (source.authType === 'app') { + return Boolean(source.appId?.trim() && source.installationId?.trim()); + } + return Boolean(source.token && source.token.trim()); + }); } const { githubConfig } = config; return !!(githubConfig.username.trim() && githubConfig.token.trim()); diff --git a/src/components/config/SourcesCard.tsx b/src/components/config/SourcesCard.tsx index 143a76a..2baf8ca 100644 --- a/src/components/config/SourcesCard.tsx +++ b/src/components/config/SourcesCard.tsx @@ -178,6 +178,10 @@ type EditorState = { url: string; username: string; token: string; + authType: "pat" | "app"; + appId: string; + installationId: string; + privateKey: string; /** Set once the lock dialog confirmed changing provider or instance URL. */ sourceUnlocked: boolean; }; @@ -210,8 +214,9 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { // An empty token field while editing keeps the stored one, so testing // and saving both fall back to it. const effectiveToken = editor?.token.trim() || editing?.token || ""; + const isApp = provider === "github" && editor?.authType === "app"; const isPublicOnlyDraft = - !!editor && !editor.username.trim() && !effectiveToken; + !!editor && !editor.username.trim() && !effectiveToken && !isApp; const refreshSources = async () => { setIsRefreshing(true); @@ -243,6 +248,10 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { url: "", username: "", token: "", + authType: "pat", + appId: "", + installationId: "", + privateKey: "", sourceUnlocked: false, }); }; @@ -254,6 +263,10 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { url: source.url ?? "", username: source.username ?? "", token: "", + authType: source.authType ?? "pat", + appId: source.appId ?? "", + installationId: source.installationId ?? "", + privateKey: "", sourceUnlocked: false, }); }; @@ -276,7 +289,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { const handleTestConnection = async () => { if (!editor) return; - if (!effectiveToken) { + if (!effectiveToken && !isApp) { toast.error(`${providerLabel} token is required to test the connection`); return; } @@ -284,7 +297,14 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { setIsTesting(true); try { - const result = await githubApi.testConnection(effectiveToken, { + const result = isApp ? await (async () => { + const response = await fetch(withBase("/api/github/test-connection"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, url: editor.url, username: editor.username.trim(), authType: "app", appId: editor.appId, installationId: editor.installationId, privateKey: editor.privateKey, sourceId: editing?.id }), + }); + return response.json(); + })() : await githubApi.testConnection(effectiveToken, { provider, url: editor.url.trim() || undefined, username: editor.username.trim() || undefined, @@ -314,13 +334,25 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { provider, username: editor.username.trim(), }; + if (provider === "github") { + body.authType = editor.authType; + if (isApp) { + body.appId = editor.appId; + body.installationId = editor.installationId; + body.privateKey = editor.privateKey; + } + } if (editor.url.trim()) { body.url = editor.url.trim(); } // Empty token on edit keeps the stored one (existing save convention). - if (!editing || editor.token.trim()) { + if (!isApp && (!editing || editor.token.trim())) { body.token = editor.token.trim(); } + if (editing?.authType === "app" && !isApp && !editor.token.trim()) { + toast.error("Enter a token to stop using the GitHub App credentials"); + return; + } if (editing && (confirmSourceChange || editor.sourceUnlocked)) { body.confirmSourceChange = true; } @@ -505,7 +537,27 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) { )} -
+ {provider === "github" && ( +
+ + +
+ )} + + {isApp ? ( +
+
+
+