Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/services/git-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions apps/api/src/services/github-token-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
43 changes: 21 additions & 22 deletions apps/api/src/services/github-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,11 @@ export type GitHubTokenContext =
| { server: true };

export async function getGitHubToken(context: GitHubTokenContext): Promise<string> {
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<string> {
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<string> {
const [task] = await db
.select({ createdBy: tasks.createdBy, workspaceId: tasks.workspaceId })
Expand All @@ -44,7 +32,7 @@ async function getTokenForTask(taskId: string): Promise<string> {

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);
}
Expand All @@ -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);
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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<string> {
async function fallbackToAppOrPat(workspaceId?: string | null): Promise<string> {
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 {
Expand Down
Loading