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
24 changes: 24 additions & 0 deletions src/lib/http-timeouts.ts
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/services/github/get-copilot-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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 () => {
const response = await fetch(
`${getGitHubApiBaseUrl()}/copilot_internal/v2/token`,
{
headers: githubHeaders(state),
signal: AbortSignal.timeout(COPILOT_TOKEN_TIMEOUT_MS),
},
)

Expand Down
2 changes: 2 additions & 0 deletions src/services/github/get-copilot-usage.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -15,6 +16,7 @@ export const getCopilotUsage = async (
`${getGitHubApiBaseUrl()}/copilot_internal/user`,
{
headers: githubHeaders(authState),
signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS),
},
)

Expand Down
2 changes: 2 additions & 0 deletions src/services/github/get-device-code.ts
Original file line number Diff line number Diff line change
@@ -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<DeviceCodeResponse> {
const { clientId, headers, scope } = getOauthAppConfig()
Expand All @@ -12,6 +13,7 @@ export async function getDeviceCode(): Promise<DeviceCodeResponse> {
client_id: clientId,
scope,
}),
signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS),
})

if (!response.ok) throw new HTTPError("Failed to get device code", response)
Expand Down
2 changes: 2 additions & 0 deletions src/services/github/get-user.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/services/github/poll-access-token.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -34,14 +35,22 @@ 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
try {
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,
Expand Down
12 changes: 12 additions & 0 deletions tests/poll-access-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading