diff --git a/src/lib/http-timeouts.ts b/src/lib/http-timeouts.ts new file mode 100644 index 0000000..4866521 --- /dev/null +++ b/src/lib/http-timeouts.ts @@ -0,0 +1,24 @@ +/** + * Request timeouts for the GitHub / Copilot auth + discovery fetches. + * + * Production runs on Bun, whose `fetch` has NO default timeout. None of these + * calls used to set one, so a half-open connection (network drop, captive + * portal, stalled TLS) could hang the operation forever — the token-refresh + * self-loop, cold-boot bootstrap, a device-code poll, or a request awaiting a + * lazy mint. Each fetch now passes `AbortSignal.timeout(...)`; on timeout it + * throws, landing in the caller's EXISTING retry/degrade/continue branch. This + * is a bounded guard, not a behavior change. + */ + +/** Copilot token mint + the refresh self-loop (`signed-in → signed-in`). The + * bearer lives ~30 min, refreshed ~25 min in — a 30s ceiling on one attempt + * leaves ample room to retry before expiry. */ +export const COPILOT_TOKEN_TIMEOUT_MS = 30_000 + +/** GitHub API reads bounded onto the cold-boot / sign-in critical path: + * `/user`, `/copilot_internal/user`, and the device-code request. */ +export const GITHUB_API_TIMEOUT_MS = 15_000 + +/** One device-code poll attempt. The overall flow is also bounded by the + * device code's own expiry (see pollAccessToken's deadline check). */ +export const DEVICE_POLL_TIMEOUT_MS = 15_000 diff --git a/src/services/github/get-copilot-token.ts b/src/services/github/get-copilot-token.ts index 257db42..5fd5a12 100644 --- a/src/services/github/get-copilot-token.ts +++ b/src/services/github/get-copilot-token.ts @@ -3,6 +3,7 @@ import consola from "consola" import { getGitHubApiBaseUrl, githubHeaders } from "~/lib/api-config" import { parseCopilotErrorBody } from "~/lib/copilot-error-parser" import { CopilotAuthFatalError, HTTPError } from "~/lib/error" +import { COPILOT_TOKEN_TIMEOUT_MS } from "~/lib/http-timeouts" import { state } from "~/lib/state" export const getCopilotToken = async () => { @@ -10,6 +11,7 @@ export const getCopilotToken = async () => { `${getGitHubApiBaseUrl()}/copilot_internal/v2/token`, { headers: githubHeaders(state), + signal: AbortSignal.timeout(COPILOT_TOKEN_TIMEOUT_MS), }, ) diff --git a/src/services/github/get-copilot-usage.ts b/src/services/github/get-copilot-usage.ts index bf06726..b0f0f9e 100644 --- a/src/services/github/get-copilot-usage.ts +++ b/src/services/github/get-copilot-usage.ts @@ -1,5 +1,6 @@ import { getGitHubApiBaseUrl, githubHeaders } from "~/lib/api-config" import { HTTPError } from "~/lib/error" +import { GITHUB_API_TIMEOUT_MS } from "~/lib/http-timeouts" import { state } from "~/lib/state" export const getCopilotUsage = async ( @@ -15,6 +16,7 @@ export const getCopilotUsage = async ( `${getGitHubApiBaseUrl()}/copilot_internal/user`, { headers: githubHeaders(authState), + signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS), }, ) diff --git a/src/services/github/get-device-code.ts b/src/services/github/get-device-code.ts index ab347eb..4908065 100644 --- a/src/services/github/get-device-code.ts +++ b/src/services/github/get-device-code.ts @@ -1,5 +1,6 @@ import { getOauthAppConfig, getOauthUrls } from "~/lib/api-config" import { HTTPError } from "~/lib/error" +import { GITHUB_API_TIMEOUT_MS } from "~/lib/http-timeouts" export async function getDeviceCode(): Promise { const { clientId, headers, scope } = getOauthAppConfig() @@ -12,6 +13,7 @@ export async function getDeviceCode(): Promise { client_id: clientId, scope, }), + signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS), }) if (!response.ok) throw new HTTPError("Failed to get device code", response) diff --git a/src/services/github/get-user.ts b/src/services/github/get-user.ts index 31793ca..271e55d 100644 --- a/src/services/github/get-user.ts +++ b/src/services/github/get-user.ts @@ -1,5 +1,6 @@ import { getGitHubApiBaseUrl, githubUserHeaders } from "~/lib/api-config" import { HTTPError } from "~/lib/error" +import { GITHUB_API_TIMEOUT_MS } from "~/lib/http-timeouts" import { state } from "~/lib/state" export async function getGitHubUser(githubToken?: string) { @@ -11,6 +12,7 @@ export async function getGitHubUser(githubToken?: string) { const authState = { ...state, githubToken: resolvedGithubToken } const response = await fetch(`${getGitHubApiBaseUrl()}/user`, { headers: githubUserHeaders(authState), + signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS), }) if (!response.ok) throw new HTTPError("Failed to get GitHub user", response) diff --git a/src/services/github/poll-access-token.ts b/src/services/github/poll-access-token.ts index 5bde666..6e56a6e 100644 --- a/src/services/github/poll-access-token.ts +++ b/src/services/github/poll-access-token.ts @@ -1,6 +1,7 @@ import consola from "consola" import { getOauthAppConfig, getOauthUrls } from "~/lib/api-config" +import { DEVICE_POLL_TIMEOUT_MS } from "~/lib/http-timeouts" import { sleep } from "~/lib/utils" import type { DeviceCodeResponse } from "./get-device-code" @@ -34,7 +35,14 @@ export async function pollAccessToken( let intervalSeconds = deviceCode.interval + 1 consola.debug(`Polling access token at ${intervalSeconds}s interval`) + // Self-expiry guard: bound the whole poll on the device code's own lifetime + // so `polling` always terminates into a terminal error even if GitHub never + // returns `expired_token` (a hung/misbehaving upstream can't poll forever). + const deadlineMs = Date.now() + deviceCode.expires_in * 1000 + while (true) { + if (Date.now() >= deadlineMs) throw new Error("expired_token") + await sleep(intervalSeconds * 1000) let response: Response @@ -42,6 +50,7 @@ export async function pollAccessToken( response = await fetch(accessTokenUrl, { method: "POST", headers, + signal: AbortSignal.timeout(DEVICE_POLL_TIMEOUT_MS), body: JSON.stringify({ client_id: clientId, device_code: deviceCode.device_code, diff --git a/tests/poll-access-token.test.ts b/tests/poll-access-token.test.ts index 9710e74..35fa261 100644 --- a/tests/poll-access-token.test.ts +++ b/tests/poll-access-token.test.ts @@ -114,4 +114,16 @@ describe("pollAccessToken (RFC 8628)", () => { expect(await pollAccessToken(DEVICE_CODE)).toBe("ghu_late") expect(fetchMock).toHaveBeenCalledTimes(2) }) + + it("self-expires once the device code's lifetime has elapsed, without polling", async () => { + // A perpetually-pending upstream would loop forever; the deadline guard + // (deviceCode.expires_in) must terminate `polling` on its own. With an + // already-elapsed lifetime, it throws before the first fetch. + const fetchMock = withResponses([{ error: "authorization_pending" }]) + await expectRejects( + () => pollAccessToken({ ...DEVICE_CODE, expires_in: 0 }), + /expired_token/, + ) + expect(fetchMock).toHaveBeenCalledTimes(0) + }) })