diff --git a/src/main/services/oauth/anthropic-claude-flow.ts b/src/main/services/oauth/anthropic-claude-flow.ts index df8907b8..a3f885cf 100644 --- a/src/main/services/oauth/anthropic-claude-flow.ts +++ b/src/main/services/oauth/anthropic-claude-flow.ts @@ -9,6 +9,7 @@ import { startOAuthCallbackServer } from "./oauth-callback-server"; import { generatePkce } from "./pkce"; import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; import { httpFailure } from "../provider-http"; +import { oauthFetch } from "./oauth-fetch"; const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; @@ -32,8 +33,8 @@ function buildAuthorizeUrl(challenge: string, state: string): string { return `${AUTHORIZE_URL}?${params.toString()}`; } -async function exchangeCode(code: string, state: string, verifier: string): Promise { - const response = await fetch(TOKEN_URL, { +async function exchangeCode(code: string, state: string, verifier: string, cancel?: AbortSignal): Promise { + const { response, text } = await oauthFetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ @@ -44,8 +45,7 @@ async function exchangeCode(code: string, state: string, verifier: string): Prom redirect_uri: REDIRECT_URI, code_verifier: verifier, }), - }); - const text = await response.text(); + }, cancel); if (!response.ok) throw new Error(`Token exchange failed: ${response.status} ${text}`); const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") { @@ -56,12 +56,11 @@ async function exchangeCode(code: string, state: string, verifier: string): Prom /** Refresh an expired Claude Pro/Max access token. */ export async function refreshClaudePro(refreshToken: string): Promise { - const response = await fetch(TOKEN_URL, { + const { response, text } = await oauthFetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken }), }); - const text = await response.text(); if (!response.ok) throw httpFailure(`Claude token refresh failed: ${response.status} ${text}`, response, text); const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") { @@ -79,7 +78,7 @@ export const anthropicClaudeFlow: OAuthFlow = { callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), instructions: "Complete the Claude sign-in in your browser." }); const result = await server.waitForCode(); if (result.state !== verifier) throw new Error("OAuth state mismatch."); - return await exchangeCode(result.code, result.state, verifier); + return await exchangeCode(result.code, result.state, verifier, callbacks.signal); } finally { server.close(); } diff --git a/src/main/services/oauth/github-copilot-flow.ts b/src/main/services/oauth/github-copilot-flow.ts index 32b3b022..ef3c7dbb 100644 --- a/src/main/services/oauth/github-copilot-flow.ts +++ b/src/main/services/oauth/github-copilot-flow.ts @@ -7,6 +7,7 @@ import { listProviderModels } from "../../../shared/provider-models"; import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; import { httpFailure } from "../provider-http"; +import { oauthFetch } from "./oauth-fetch"; // Decoded from the published GitHub Copilot client id used by the official CLI. const CLIENT_ID = "Iv1.b507a08c87ecfe98"; @@ -44,13 +45,10 @@ function getUrls(domain: string): CopilotUrls { }; } -async function fetchJson(url: string, init: RequestInit): Promise> { - const response = await fetch(url, init); - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw httpFailure(`${response.status} ${response.statusText}: ${text}`, response, text); - } - return (await response.json()) as Record; +async function fetchJson(url: string, init: RequestInit, cancel?: AbortSignal): Promise> { + const { response, text } = await oauthFetch(url, init, cancel); + if (!response.ok) throw httpFailure(`${response.status} ${response.statusText}: ${text}`, response, text); + return JSON.parse(text) as Record; } interface DeviceCode { @@ -61,7 +59,7 @@ interface DeviceCode { expires_in: number; } -async function startDeviceFlow(domain: string): Promise { +async function startDeviceFlow(domain: string, cancel?: AbortSignal): Promise { const data = await fetchJson(getUrls(domain).deviceCodeUrl, { method: "POST", headers: { @@ -70,7 +68,7 @@ async function startDeviceFlow(domain: string): Promise { "User-Agent": COPILOT_HEADERS["User-Agent"], }, body: new URLSearchParams({ client_id: CLIENT_ID, scope: "read:user" }), - }); + }, cancel); const deviceCode = data.device_code; const userCode = data.user_code; const verificationUri = data.verification_uri; @@ -148,7 +146,7 @@ async function pollForGitHubAccessToken(domain: string, device: DeviceCode, sign device_code: device.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code", }), - }); + }, signal); const decision = interpretPollResponse(raw); if (decision.kind === "token") return decision.accessToken; if (decision.kind === "error") throw new Error(decision.message); @@ -160,11 +158,11 @@ async function pollForGitHubAccessToken(domain: string, device: DeviceCode, sign throw new Error("Device flow timed out. Please try signing in again."); } -export async function refreshGitHubCopilotToken(githubToken: string, enterpriseDomain?: string | null): Promise { +export async function refreshGitHubCopilotToken(githubToken: string, enterpriseDomain?: string | null, cancel?: AbortSignal): Promise { const domain = enterpriseDomain || "github.com"; const raw = await fetchJson(getUrls(domain).copilotTokenUrl, { headers: { Accept: "application/json", Authorization: `Bearer ${githubToken}`, ...COPILOT_HEADERS }, - }); + }, cancel); const token = raw.token; const expiresAt = raw.expires_at; if (typeof token !== "string" || typeof expiresAt !== "number") { @@ -186,12 +184,12 @@ export function copilotBaseUrl(token: string, enterpriseDomain?: string | null): } /** Best-effort model policy enablement, matching the official client behaviour. */ -async function enableCopilotModels(token: string, enterpriseDomain?: string | null): Promise { +async function enableCopilotModels(token: string, enterpriseDomain?: string | null, cancel?: AbortSignal): Promise { const baseUrl = copilotBaseUrl(token, enterpriseDomain); await Promise.all( listProviderModels("github-copilot").map(async (model) => { try { - await fetch(`${baseUrl}/models/${model.id}/policy`, { + await oauthFetch(`${baseUrl}/models/${model.id}/policy`, { method: "POST", headers: { "Content-Type": "application/json", @@ -201,9 +199,10 @@ async function enableCopilotModels(token: string, enterpriseDomain?: string | nu "x-interaction-type": "chat-policy", }, body: JSON.stringify({ state: "enabled" }), - }); - } catch { - // Per-model enablement is advisory; the model may already be enabled. + }, cancel); + } catch (error) { + // Per-model enablement is advisory (the model may already be enabled); a cancelled sign-in still ends here. + if (cancel?.aborted) throw error; } }), ); @@ -214,15 +213,15 @@ export const githubCopilotFlow: OAuthFlow = { async login(callbacks: OAuthLoginCallbacks): Promise { const enterpriseDomain = normalizeDomain(callbacks.enterpriseDomain); const domain = enterpriseDomain ?? "github.com"; - const device = await startDeviceFlow(domain); + const device = await startDeviceFlow(domain, callbacks.signal); callbacks.onAuth({ url: device.verification_uri, instructions: `Enter code ${device.user_code} at ${device.verification_uri}`, }); const githubToken = await pollForGitHubAccessToken(domain, device, callbacks.signal); - const credentials = await refreshGitHubCopilotToken(githubToken, enterpriseDomain ?? undefined); + const credentials = await refreshGitHubCopilotToken(githubToken, enterpriseDomain ?? undefined, callbacks.signal); callbacks.onProgress?.("Enabling Copilot models"); - await enableCopilotModels(credentials.access, enterpriseDomain ?? undefined); + await enableCopilotModels(credentials.access, enterpriseDomain ?? undefined, callbacks.signal); return credentials; }, }; diff --git a/src/main/services/oauth/google-antigravity-flow.ts b/src/main/services/oauth/google-antigravity-flow.ts index f96e28a5..643fa962 100644 --- a/src/main/services/oauth/google-antigravity-flow.ts +++ b/src/main/services/oauth/google-antigravity-flow.ts @@ -8,6 +8,7 @@ import { startOAuthCallbackServer } from "./oauth-callback-server"; import { generatePkce } from "./pkce"; import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; import { httpFailure } from "../provider-http"; +import { oauthFetch } from "./oauth-fetch"; const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; const CALLBACK_PORT = 51121; @@ -44,8 +45,8 @@ function buildAuthorizeUrl(challenge: string, state: string): string { return `${AUTH_URL}?${params.toString()}`; } -async function exchangeCode(code: string, verifier: string, clientSecret: string): Promise<{ access: string; refresh: string; expires: number }> { - const response = await fetch(TOKEN_URL, { +async function exchangeCode(code: string, verifier: string, clientSecret: string, cancel?: AbortSignal): Promise<{ access: string; refresh: string; expires: number }> { + const { response, text } = await oauthFetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -56,12 +57,9 @@ async function exchangeCode(code: string, verifier: string, clientSecret: string redirect_uri: REDIRECT_URI, code_verifier: verifier, }), - }); - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error(`Token exchange failed: ${response.status} ${text}`); - } - const json = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; + }, cancel); + if (!response.ok) throw new Error(`Token exchange failed: ${response.status} ${text}`); + const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") { throw new Error("Token response was missing required fields."); } @@ -79,7 +77,7 @@ export function readProjectId(payload: Record): string | null { return null; } -async function discoverProject(accessToken: string): Promise { +async function discoverProject(accessToken: string, cancel?: AbortSignal): Promise { const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", @@ -87,17 +85,18 @@ async function discoverProject(accessToken: string): Promise { }; for (const endpoint of CODE_ASSIST_ENDPOINTS) { try { - const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + const { response, text } = await oauthFetch(`${endpoint}/v1internal:loadCodeAssist`, { method: "POST", headers, body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } }), - }); + }, cancel); if (response.ok) { - const projectId = readProjectId((await response.json()) as Record); + const projectId = readProjectId(JSON.parse(text) as Record); if (projectId) return projectId; } - } catch { - // Try the next endpoint. + } catch (error) { + // A cancelled sign-in ends here; any other failure moves on to the next endpoint. + if (cancel?.aborted) throw error; } } throw new Error("Antigravity project discovery failed."); @@ -105,7 +104,7 @@ async function discoverProject(accessToken: string): Promise { /** Refresh an expired Antigravity access token, retaining the project id. */ export async function refreshAntigravity(refreshToken: string, projectId: string, clientSecret: string): Promise { - const response = await fetch(TOKEN_URL, { + const { response, text } = await oauthFetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -115,7 +114,6 @@ export async function refreshAntigravity(refreshToken: string, projectId: string grant_type: "refresh_token", }), }); - const text = await response.text(); if (!response.ok) throw httpFailure(`Antigravity token refresh failed: ${response.status} ${text}`, response, text); const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") { @@ -135,9 +133,9 @@ export const antigravityFlow: OAuthFlow = { callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), instructions: "Complete the Google sign-in in your browser." }); const result = await server.waitForCode(); if (result.state !== verifier) throw new Error("OAuth state mismatch."); - const tokens = await exchangeCode(result.code, verifier, clientSecret); + const tokens = await exchangeCode(result.code, verifier, clientSecret, callbacks.signal); callbacks.onProgress?.("Resolving project"); - const projectId = await discoverProject(tokens.access); + const projectId = await discoverProject(tokens.access, callbacks.signal); return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, projectId }; } finally { server.close(); diff --git a/src/main/services/oauth/kimi-code-flow.ts b/src/main/services/oauth/kimi-code-flow.ts index d8d8cc53..5f646946 100644 --- a/src/main/services/oauth/kimi-code-flow.ts +++ b/src/main/services/oauth/kimi-code-flow.ts @@ -9,6 +9,7 @@ import { createHash } from "node:crypto"; import { hostname, platform as osPlatform, release as osRelease, userInfo } from "node:os"; import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; import { httpFailure } from "../provider-http"; +import { oauthFetch } from "./oauth-fetch"; // Published public client id of the official Kimi CLI; reused by third-party // coding agents per Kimi's device-flow integration guidance. @@ -48,8 +49,8 @@ function deviceHeaders(): Record { }; } -async function postForm(path: string, form: Record): Promise<{ status: number; data: Record; response: Response; text: string }> { - const response = await fetch(`${OAUTH_HOST}${path}`, { +async function postForm(path: string, form: Record, cancel?: AbortSignal): Promise<{ status: number; data: Record; response: Response; text: string }> { + const { response, text } = await oauthFetch(`${OAUTH_HOST}${path}`, { method: "POST", headers: { Accept: "application/json", @@ -57,8 +58,7 @@ async function postForm(path: string, form: Record): Promise<{ s ...deviceHeaders(), }, body: new URLSearchParams(form), - }); - const text = await response.text(); + }, cancel); let data: unknown; try { data = JSON.parse(text); @@ -79,8 +79,8 @@ interface DeviceAuthorization { interval: number; } -async function requestDeviceAuthorization(): Promise { - const { status, data } = await postForm("/api/oauth/device_authorization", { client_id: CLIENT_ID }); +async function requestDeviceAuthorization(cancel?: AbortSignal): Promise { + const { status, data } = await postForm("/api/oauth/device_authorization", { client_id: CLIENT_ID }, cancel); if (status !== 200) throw new Error(`Kimi device authorization failed: ${status} ${JSON.stringify(data)}`); const deviceCode = data.device_code; const userCode = data.user_code; @@ -157,7 +157,7 @@ async function pollForToken(device: DeviceAuthorization, signal?: AbortSignal): client_id: CLIENT_ID, device_code: device.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }); + }, signal); const decision = interpretKimiPollResponse(status, data); if (decision.kind === "token") return decision.credentials; if (decision.kind === "expired") throw new Error("Kimi device code expired. Please try signing in again."); @@ -180,7 +180,7 @@ export async function refreshKimiCode(refreshToken: string): Promise { - const device = await requestDeviceAuthorization(); + const device = await requestDeviceAuthorization(callbacks.signal); callbacks.onAuth({ url: device.verification_uri_complete, instructions: `Approve code ${device.user_code} in your browser to link your Kimi plan.`, diff --git a/src/main/services/oauth/oauth-fetch.ts b/src/main/services/oauth/oauth-fetch.ts new file mode 100644 index 00000000..f91bd87c --- /dev/null +++ b/src/main/services/oauth/oauth-fetch.ts @@ -0,0 +1,24 @@ +import { ProviderHttpError } from "../provider-http"; + +/** How long one sign-in or renewal request may take, body included. */ +export const OAUTH_REQUEST_TIMEOUT_MS = 30_000; + +/** A sign-in or renewal request that ends on the user's cancel or a stalled server; runs stop only their own wait, since refreshes are shared. */ +export async function oauthFetch(url: string, init: RequestInit, cancel?: AbortSignal): Promise<{ response: Response; text: string }> { + const controller = new AbortController(); + const abort = () => controller.abort(); + if (cancel?.aborted) controller.abort(); + else cancel?.addEventListener("abort", abort, { once: true }); + const timer = setTimeout(abort, OAUTH_REQUEST_TIMEOUT_MS); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + return { response, text: await response.text() }; + } catch (error) { + if (cancel?.aborted) throw new Error("Login cancelled"); + if (controller.signal.aborted) throw new ProviderHttpError(`Sign-in request to ${new URL(url).host} timed out.`, null, null, true); + throw error; + } finally { + clearTimeout(timer); + cancel?.removeEventListener("abort", abort); + } +} diff --git a/src/main/services/oauth/openai-codex-flow.ts b/src/main/services/oauth/openai-codex-flow.ts index 7335b6e5..5875ab31 100644 --- a/src/main/services/oauth/openai-codex-flow.ts +++ b/src/main/services/oauth/openai-codex-flow.ts @@ -8,6 +8,7 @@ import { startOAuthCallbackServer } from "./oauth-callback-server"; import { generatePkce } from "./pkce"; import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; import { httpFailure } from "../provider-http"; +import { oauthFetch } from "./oauth-fetch"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; @@ -54,8 +55,8 @@ export function extractAccountId(accessToken: string): string | null { return null; } -async function exchangeCode(code: string, verifier: string): Promise<{ access: string; refresh: string; expires: number }> { - const response = await fetch(TOKEN_URL, { +async function exchangeCode(code: string, verifier: string, cancel?: AbortSignal): Promise<{ access: string; refresh: string; expires: number }> { + const { response, text } = await oauthFetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -65,12 +66,9 @@ async function exchangeCode(code: string, verifier: string): Promise<{ access: s code_verifier: verifier, redirect_uri: REDIRECT_URI, }), - }); - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error(`Token exchange failed: ${response.status} ${text}`); - } - const json = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; + }, cancel); + if (!response.ok) throw new Error(`Token exchange failed: ${response.status} ${text}`); + const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") { throw new Error("Token response was missing required fields."); } @@ -79,12 +77,11 @@ async function exchangeCode(code: string, verifier: string): Promise<{ access: s /** Refresh an expired Codex access token using the long-lived refresh token. */ export async function refreshCodex(refreshToken: string): Promise { - const response = await fetch(TOKEN_URL, { + const { response, text } = await oauthFetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }), }); - const text = await response.text(); if (!response.ok) throw httpFailure(`Codex token refresh failed: ${response.status} ${text}`, response, text); const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") { @@ -105,7 +102,7 @@ export const openaiCodexFlow: OAuthFlow = { callbacks.onAuth({ url: buildAuthorizeUrl(challenge, state), instructions: "Complete the ChatGPT sign-in in your browser." }); const result = await server.waitForCode(); if (result.state !== state) throw new Error("OAuth state mismatch."); - const tokens = await exchangeCode(result.code, verifier); + const tokens = await exchangeCode(result.code, verifier, callbacks.signal); const accountId = extractAccountId(tokens.access); if (!accountId) throw new Error("Could not read the ChatGPT account id from the token."); return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, accountId }; diff --git a/src/main/services/provider-tool-turns/credential-resolver.ts b/src/main/services/provider-tool-turns/credential-resolver.ts index 468dfd6c..77aa3698 100644 --- a/src/main/services/provider-tool-turns/credential-resolver.ts +++ b/src/main/services/provider-tool-turns/credential-resolver.ts @@ -31,6 +31,7 @@ import type { IntegrationKeyStore } from "../integration-key-store"; import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow"; import { refreshKimiCode } from "../oauth/kimi-code-flow"; import { parseStoredOAuth, type StoredOAuth } from "../oauth/stored-oauth"; +import { untilStopped } from "../run-control"; function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: IntegrationKeyStore): Promise { switch (providerKey) { @@ -101,7 +102,8 @@ export async function resolveTurnCredential(deps: { } const creds = parseStoredOAuth(deps.provider.provider_key, secret); if (Date.now() < creds.expires) return toTurnCredential(creds); - return toTurnCredential(await refreshOnce(deps, creds)); + // Stopping this run ends its own wait; the shared refresh carries on for the other runs. + return toTurnCredential(await untilStopped(refreshOnce(deps, creds))); } /** Refreshes in flight, by stored credential: a rotating provider honours a refresh token once, so callers at once share one. */ diff --git a/src/main/services/provider-tool-turns/gcp-auth.ts b/src/main/services/provider-tool-turns/gcp-auth.ts index a4f4cc79..4b2db7da 100644 --- a/src/main/services/provider-tool-turns/gcp-auth.ts +++ b/src/main/services/provider-tool-turns/gcp-auth.ts @@ -5,6 +5,7 @@ */ import { createSign } from "node:crypto"; import { httpFailure } from "../provider-http"; +import { oauthFetch } from "../oauth/oauth-fetch"; interface ServiceAccount { client_email: string; @@ -43,12 +44,11 @@ export async function getGcpAccessToken(serviceAccountJson: string): Promise Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") }; @@ -57,6 +58,18 @@ describe("an expired sign-in is refreshed once, however many runs need it", () = h.connection.close(); }); + test("stopping one run ends its wait on the shared refresh; the other run still gets the new sign-in", async () => { + const calls = rotatingTokenEndpoint(); + const h = expiredClaudePro(); + const stopped = withRunControl("run-stopped", () => h.resolve(), 60_000); + const other = withRunControl("run-other", () => h.resolve(), 60_000); + stopRun("run-stopped", "Stopped by the operator."); + await expect(stopped).rejects.toThrow("Stopped by the operator."); + expect((await other).accessToken).toBe("access-1"); + expect(calls).toEqual(["refresh-0"]); + h.connection.close(); + }); + test("a run that comes after the refresh uses the stored new sign-in", async () => { const calls = rotatingTokenEndpoint(); const h = expiredClaudePro(); diff --git a/tests/unit/oauth-fetch.test.ts b/tests/unit/oauth-fetch.test.ts new file mode 100644 index 00000000..9bead6ee --- /dev/null +++ b/tests/unit/oauth-fetch.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { oauthFetch, OAUTH_REQUEST_TIMEOUT_MS } from "../../src/main/services/oauth/oauth-fetch"; +import { ProviderHttpError } from "../../src/main/services/provider-http"; + +/** A server that accepts the connection and never answers, until the request is aborted. */ +function stalledServer(): void { + vi.stubGlobal("fetch", (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); + })); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("sign-in and renewal requests end when they should", () => { + test("cancelling a sign-in ends the wait on a stalled token exchange", async () => { + stalledServer(); + const cancel = new AbortController(); + const pending = oauthFetch("https://auth.example.test/token", { method: "POST" }, cancel.signal); + cancel.abort(); + await expect(pending).rejects.toThrow("Login cancelled"); + }); + + test("a stalled exchange or refresh fails as a timeout instead of waiting forever", async () => { + vi.useFakeTimers(); + stalledServer(); + const pending = oauthFetch("https://auth.example.test/token", { method: "POST" }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(OAUTH_REQUEST_TIMEOUT_MS); + const failure = await pending; + expect(failure).toBeInstanceOf(ProviderHttpError); + expect(failure).toMatchObject({ timedOut: true, message: "Sign-in request to auth.example.test timed out." }); + }); + + test("a request that answers returns its status and body", async () => { + vi.stubGlobal("fetch", async () => new Response("{\"ok\":true}", { status: 200 })); + const { response, text } = await oauthFetch("https://auth.example.test/token", { method: "POST" }); + expect([response.status, text]).toEqual([200, "{\"ok\":true}"]); + }); +});