From 1455b18bbe336af232527d13d9f6ffd7819b9898 Mon Sep 17 00:00:00 2001 From: Daniel Genis Date: Thu, 7 May 2026 09:32:14 +0200 Subject: [PATCH] fix(github-token): prefer App installation token over PAT in user fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user has no stored GitHub OAuth token (e.g., logged in via a non-GitHub OAuth provider) the cascade fell straight to the PAT, ignoring a configured GitHub App. Deployments using only an App for server-side auth therefore failed with "No GitHub token available" on every task credential request, which broke scheduled tasks at the agent's first git operation. Route all user-token fallbacks through getServerToken so the order becomes: user OAuth → App installation token → workspace PAT → global PAT. getServerToken now accepts an optional workspaceId so the PAT fallback still prefers a workspace-scoped GITHUB_TOKEN before global. --- apps/api/src/services/github-token-service.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/api/src/services/github-token-service.ts b/apps/api/src/services/github-token-service.ts index 7c329ac6..cceb9f70 100644 --- a/apps/api/src/services/github-token-service.ts +++ b/apps/api/src/services/github-token-service.ts @@ -16,24 +16,24 @@ const TOKEN_REFRESH_BUFFER_MS = 10 * 60 * 1000; export type GitHubTokenContext = | { taskId: string } | { userId: string; workspaceId?: string | null } - | { server: true }; + | { server: true; workspaceId?: string | null }; export async function getGitHubToken(context: GitHubTokenContext): Promise { - if ("server" in context) return getServerToken(); + if ("server" in context) return getServerToken(context.workspaceId); if ("taskId" in context) return getTokenForTask(context.taskId); return getTokenForUser(context.userId, context.workspaceId); } -async function getServerToken(): Promise { +async function getServerToken(workspaceId?: string | null): Promise { if (isGitHubAppConfigured()) { try { return await getInstallationToken(); } catch (err) { logger.warn({ err }, "Installation token failed, falling back to PAT"); - return getPatFallback(); + return getPatFallback(workspaceId); } } - return getPatFallback(); + return getPatFallback(workspaceId); } async function getTokenForTask(taskId: string): Promise { @@ -44,7 +44,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 getServerToken(task?.workspaceId); } return getTokenForUser(task.createdBy, task.workspaceId); } @@ -60,8 +60,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 server token"); + return getServerToken(workspaceId); } } @@ -84,7 +84,7 @@ async function doRefreshUserToken(userId: string, workspaceId?: string | null): if (!clientId || !clientSecret) { await deleteUserGitHubTokens(userId); - return getPatFallback(workspaceId); + return getServerToken(workspaceId); } try { @@ -128,8 +128,8 @@ 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 server token"); + return getServerToken(workspaceId); } }