From 9bba31975aeef1fc32821137d065ff65171dfa6b Mon Sep 17 00:00:00 2001 From: Daniel Genis Date: Thu, 21 May 2026 09:20:22 +0200 Subject: [PATCH] fix(auth): fall back to GitHub App installation token when user has no OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks created by users who signed in via non-GitHub providers (Google/OIDC) were failing with `agent_no_output`. The credential helper called /api/internal/git-credentials, which returned 500 because getTokenForUser only fell back to PAT (`user OAuth → PAT → throw`) and never tried the installed GitHub App. Without a PAT configured, the helper returned nothing, git prompted for credentials on a non-TTY stdin, and the agent's entrypoint died on `set -e` before the agent could start. Reorder the fallback chain to `user OAuth → App installation → PAT` (the order the existing docstring and error message already implied). Folds `getPatFallback` and `getServerToken` into a single `fallbackToAppOrPat` helper used by both user-scoped and server-scoped lookups, removing two layers of indirection. Also stops the recurring `Secret not found` errors from pr-watcher and reconcile-snapshot that hit the same code path on every poll cycle. --- apps/api/src/services/git-token-service.ts | 2 +- .../src/services/github-token-service.test.ts | 65 +++++++++++++++++++ apps/api/src/services/github-token-service.ts | 43 ++++++------ 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/apps/api/src/services/git-token-service.ts b/apps/api/src/services/git-token-service.ts index bf284c78..d6d418b4 100644 --- a/apps/api/src/services/git-token-service.ts +++ b/apps/api/src/services/git-token-service.ts @@ -13,7 +13,7 @@ export interface GitTokenContext { /** * Resolve a git platform token for the given platform and context. - * GitHub: delegates to the existing github-token-service (App → user OAuth → PAT). + * GitHub: delegates to the existing github-token-service (user OAuth → App → PAT). * GitLab: checks GITLAB_TOKEN secret (workspace-scoped → global). */ export async function getGitToken( diff --git a/apps/api/src/services/github-token-service.test.ts b/apps/api/src/services/github-token-service.test.ts index 7dc2b32f..a1443ce9 100644 --- a/apps/api/src/services/github-token-service.test.ts +++ b/apps/api/src/services/github-token-service.test.ts @@ -405,4 +405,69 @@ describe("github-token-service", () => { expect(mockDeleteSecret).toHaveBeenCalledWith("GITHUB_USER_REFRESH_TOKEN", "user:user-7"); expect(mockDeleteSecret).toHaveBeenCalledWith("GITHUB_USER_TOKEN_EXPIRES_AT", "user:user-7"); }); + + it("falls back to installation token when user has no OAuth and GitHub App is configured", async () => { + mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); + mockIsConfigured.mockReturnValue(true); + mockGetInstToken.mockResolvedValue("ghs_install_token"); + + const token = await getGitHubToken({ userId: "user-google" }); + + expect(token).toBe("ghs_install_token"); + expect(mockGetInstToken).toHaveBeenCalled(); + expect(mockRetrieveSecretWithFallback).not.toHaveBeenCalled(); + }); + + it("falls back to PAT when user has no OAuth, App is configured but installation token fails", async () => { + mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); + mockIsConfigured.mockReturnValue(true); + mockGetInstToken.mockRejectedValue(new Error("Installation 404")); + mockRetrieveSecretWithFallback.mockResolvedValue("ghp_pat_token"); + + const token = await getGitHubToken({ userId: "user-google", workspaceId: "ws-x" }); + + expect(token).toBe("ghp_pat_token"); + expect(mockRetrieveSecretWithFallback).toHaveBeenCalledWith("GITHUB_TOKEN", "global", "ws-x"); + }); + + it("throws when user has no OAuth, no GitHub App, and no PAT", async () => { + mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); + mockIsConfigured.mockReturnValue(false); + mockRetrieveSecretWithFallback.mockRejectedValue(new Error("Secret not found")); + + await expect(getGitHubToken({ userId: "user-x" })).rejects.toThrow("No GitHub token available"); + }); + + it("uses installation token for task-scoped lookup when task creator has no OAuth", async () => { + mockDbWhere.mockResolvedValue([{ createdBy: "user-google", workspaceId: "ws-3" }]); + mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); + mockIsConfigured.mockReturnValue(true); + mockGetInstToken.mockResolvedValue("ghs_task_install_token"); + + const token = await getGitHubToken({ taskId: "task-with-google-creator" }); + + expect(token).toBe("ghs_task_install_token"); + }); + + it("falls back to installation token when token refresh fails and App is configured", async () => { + const pastDate = new Date(Date.now() - 60 * 1000).toISOString(); + mockRetrieveSecret + .mockResolvedValueOnce("ghu_expired") + .mockResolvedValueOnce(pastDate) + .mockResolvedValueOnce("ghr_refresh"); + + process.env.GITHUB_APP_CLIENT_ID = "client-id"; + process.env.GITHUB_APP_CLIENT_SECRET = "client-secret"; + + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 }); + globalThis.fetch = mockFetch; + + mockIsConfigured.mockReturnValue(true); + mockGetInstToken.mockResolvedValue("ghs_post_refresh_install"); + + const token = await getGitHubToken({ userId: "user-refresh-fail" }); + + expect(token).toBe("ghs_post_refresh_install"); + expect(mockGetInstToken).toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/services/github-token-service.ts b/apps/api/src/services/github-token-service.ts index 7c329ac6..42546c8d 100644 --- a/apps/api/src/services/github-token-service.ts +++ b/apps/api/src/services/github-token-service.ts @@ -19,23 +19,11 @@ export type GitHubTokenContext = | { server: true }; export async function getGitHubToken(context: GitHubTokenContext): Promise { - if ("server" in context) return getServerToken(); + if ("server" in context) return fallbackToAppOrPat(); if ("taskId" in context) return getTokenForTask(context.taskId); return getTokenForUser(context.userId, context.workspaceId); } -async function getServerToken(): Promise { - if (isGitHubAppConfigured()) { - try { - return await getInstallationToken(); - } catch (err) { - logger.warn({ err }, "Installation token failed, falling back to PAT"); - return getPatFallback(); - } - } - return getPatFallback(); -} - async function getTokenForTask(taskId: string): Promise { const [task] = await db .select({ createdBy: tasks.createdBy, workspaceId: tasks.workspaceId }) @@ -44,7 +32,7 @@ async function getTokenForTask(taskId: string): Promise { if (!task?.createdBy) { // No user associated — use server/installation token (e.g., system-created tasks) - return getServerToken(); + return fallbackToAppOrPat(); } return getTokenForUser(task.createdBy, task.workspaceId); } @@ -60,8 +48,8 @@ async function getTokenForUser(userId: string, workspaceId?: string | null): Pro } return refreshUserToken(userId, workspaceId); } catch (err) { - logger.warn({ userId, err }, "No stored user token, falling back to PAT"); - return getPatFallback(workspaceId); + logger.warn({ userId, err }, "No stored user token, falling back to App/PAT"); + return fallbackToAppOrPat(workspaceId); } } @@ -84,7 +72,7 @@ async function doRefreshUserToken(userId: string, workspaceId?: string | null): if (!clientId || !clientSecret) { await deleteUserGitHubTokens(userId); - return getPatFallback(workspaceId); + return fallbackToAppOrPat(workspaceId); } try { @@ -128,16 +116,27 @@ async function doRefreshUserToken(userId: string, workspaceId?: string | null): } catch (err) { // Don't delete tokens on transient errors (network, 5xx) — only the // definitive revocation cases above delete them before re-throwing. - logger.warn({ userId, err }, "Token refresh failed, falling back to PAT"); - return getPatFallback(workspaceId); + logger.warn({ userId, err }, "Token refresh failed, falling back to App/PAT"); + return fallbackToAppOrPat(workspaceId); } } /** - * Last-resort fallback: try to retrieve a manually-configured GITHUB_TOKEN PAT. - * Returns the token if found, throws a descriptive error if not. + * Fallback chain when no user OAuth token is available: + * GitHub App installation token → GITHUB_TOKEN PAT → throw. + * + * Used by both user-scoped lookups (user signed in via a non-GitHub provider, + * or OAuth missing/unrefreshable) and server-scoped lookups (system-created + * tasks, PR watcher, reconciler). */ -async function getPatFallback(workspaceId?: string | null): Promise { +async function fallbackToAppOrPat(workspaceId?: string | null): Promise { + if (isGitHubAppConfigured()) { + try { + return await getInstallationToken(); + } catch (err) { + logger.warn({ err }, "Installation token failed, falling back to PAT"); + } + } try { return await retrieveSecretWithFallback("GITHUB_TOKEN", "global", workspaceId); } catch {