Skip to content

Commit 73a9471

Browse files
authored
Harden GitHub auth context and cancel abandoned requests (#71)
- Catch uncaught token refresh errors in getGitHubAppUserInstallations and getGitHubUserContextForOwner so a failed refresh falls back to the OAuth client instead of crashing the Worker - Eagerly verify installation auth in getGitHubContextForOwner since @octokit/auth-app authenticates lazily and the existing try/catch could never catch auth failures - Skip suspended GitHub App installations - Cache resolved GitHub contexts per-request (WeakMap<Request>) so parallel server functions reuse one session/installation lookup - Propagate the incoming HTTP request abort signal to GitHub API calls via AbortSignal.any so navigating away cancels in-flight work - Add debug() logging to the auth and context resolution flow
1 parent 2b173cd commit 73a9471

3 files changed

Lines changed: 217 additions & 84 deletions

File tree

apps/dashboard/src/lib/github-app.server.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { env } from "cloudflare:workers";
33
import { and, eq } from "drizzle-orm";
44
import { getDb } from "../db";
55
import { account } from "../db/schema";
6+
import { debug } from "./debug";
67
import { normalizeGitHubAppPrivateKey } from "./github-private-key";
78
import { GITHUB_REQUEST_TIMEOUT_MS } from "./github-request-policy";
89

@@ -196,19 +197,25 @@ async function refreshGitHubAppUserToken({
196197
userId: string;
197198
}) {
198199
const githubApp = getGitHubAppAuthConfig();
199-
const payload = await requestGitHubAppUserToken({
200-
client_id: githubApp.clientId,
201-
client_secret: githubApp.clientSecret,
202-
grant_type: "refresh_token",
203-
refresh_token: refreshToken,
204-
});
205-
206-
await saveGitHubAppUserToken({
207-
userId,
208-
payload,
209-
});
210-
211-
return payload.access_token;
200+
try {
201+
const payload = await requestGitHubAppUserToken({
202+
client_id: githubApp.clientId,
203+
client_secret: githubApp.clientSecret,
204+
grant_type: "refresh_token",
205+
refresh_token: refreshToken,
206+
});
207+
208+
await saveGitHubAppUserToken({
209+
userId,
210+
payload,
211+
});
212+
213+
debug("github-auth", "app user token refreshed", { userId });
214+
return payload.access_token;
215+
} catch (error) {
216+
console.error("[github-auth] app user token refresh failed", userId, error);
217+
throw error;
218+
}
212219
}
213220

214221
async function saveGitHubAppUserToken({
@@ -258,20 +265,29 @@ async function saveGitHubAppUserToken({
258265
export async function getGitHubAppUserAccessTokenByUserId(userId: string) {
259266
const githubAccount = await getGitHubAppUserAccountByUserId(userId);
260267
if (!githubAccount?.accessToken) {
268+
debug("github-auth", "no app user account", { userId });
261269
return null;
262270
}
263271

264272
if (isUsableAccessTokenExpiresAt(githubAccount.accessTokenExpiresAt)) {
273+
debug("github-auth", "app user token is fresh", { userId });
265274
return githubAccount.accessToken;
266275
}
267276

268277
if (
269278
!githubAccount.refreshToken ||
270279
!isUsableAccessTokenExpiresAt(githubAccount.refreshTokenExpiresAt)
271280
) {
281+
debug("github-auth", "app user token expired, refresh unavailable", {
282+
userId,
283+
hasRefreshToken: Boolean(githubAccount.refreshToken),
284+
refreshTokenExpiresAt:
285+
githubAccount.refreshTokenExpiresAt?.toISOString() ?? null,
286+
});
272287
return null;
273288
}
274289

290+
debug("github-auth", "app user token expired, refreshing", { userId });
275291
return refreshGitHubAppUserToken({
276292
refreshToken: githubAccount.refreshToken,
277293
userId,

apps/dashboard/src/lib/github-request-policy.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getRequest } from "@tanstack/react-start/server";
12
import type { Octokit as OctokitType } from "octokit";
23

34
const GITHUB_READ_RETRY_COUNT = 1;
@@ -19,8 +20,28 @@ function isSafeGitHubRetryMethod(method: string | undefined) {
1920
return method === "GET" || method === "HEAD" || method === "OPTIONS";
2021
}
2122

22-
function createGitHubRequestTimeoutSignal() {
23-
return AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS);
23+
function createGitHubRequestTimeoutSignal(
24+
requestSignal: AbortSignal | undefined,
25+
) {
26+
const timeoutSignal = AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS);
27+
if (!requestSignal) {
28+
return timeoutSignal;
29+
}
30+
31+
return AbortSignal.any([requestSignal, timeoutSignal]);
32+
}
33+
34+
/**
35+
* Returns the incoming HTTP request's abort signal when available.
36+
* When the client navigates away the signal fires, letting us cancel
37+
* in-flight GitHub requests instead of running them to completion.
38+
*/
39+
function getIncomingRequestSignal(): AbortSignal | undefined {
40+
try {
41+
return getRequest().signal;
42+
} catch {
43+
return undefined;
44+
}
2445
}
2546

2647
export function configureGitHubRequestPolicies(octokit: OctokitType) {
@@ -30,6 +51,8 @@ export function configureGitHubRequestPolicies(octokit: OctokitType) {
3051
requestOptions.retries = isSafeGitHubRetryMethod(options.method)
3152
? GITHUB_READ_RETRY_COUNT
3253
: 0;
33-
requestOptions.signal ??= createGitHubRequestTimeoutSignal();
54+
requestOptions.signal ??= createGitHubRequestTimeoutSignal(
55+
getIncomingRequestSignal(),
56+
);
3457
});
3558
}

0 commit comments

Comments
 (0)