Q91: sign-in and renewal requests end on cancel or a stalled server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 15:17:31 -04:00
1 parent bcab38f003
commit 6655982368
10 files changed
+140 -67

No files matched your search

@@ -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<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
async function exchangeCode(code: string, state: string, verifier: string, cancel?: AbortSignal): Promise<OAuthCredentials> {
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<OAuthCredentials> {
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();
}
+19 -20
View File
@@ -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<Record<string, unknown>> {
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<string, unknown>;
async function fetchJson(url: string, init: RequestInit, cancel?: AbortSignal): Promise<Record<string, unknown>> {
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<string, unknown>;
}
interface DeviceCode {
@@ -61,7 +59,7 @@ interface DeviceCode {
expires_in: number;
}
async function startDeviceFlow(domain: string): Promise<DeviceCode> {
async function startDeviceFlow(domain: string, cancel?: AbortSignal): Promise<DeviceCode> {
const data = await fetchJson(getUrls(domain).deviceCodeUrl, {
method: "POST",
headers: {
@@ -70,7 +68,7 @@ async function startDeviceFlow(domain: string): Promise<DeviceCode> {
"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<OAuthCredentials> {
export async function refreshGitHubCopilotToken(githubToken: string, enterpriseDomain?: string | null, cancel?: AbortSignal): Promise<OAuthCredentials> {
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<void> {
async function enableCopilotModels(token: string, enterpriseDomain?: string | null, cancel?: AbortSignal): Promise<void> {
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<OAuthCredentials> {
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;
},
};
@@ -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, unknown>): string | null {
return null;
}
async function discoverProject(accessToken: string): Promise<string> {
async function discoverProject(accessToken: string, cancel?: AbortSignal): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
@@ -87,17 +85,18 @@ async function discoverProject(accessToken: string): Promise<string> {
};
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<string, unknown>);
const projectId = readProjectId(JSON.parse(text) as Record<string, unknown>);
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<string> {
/** Refresh an expired Antigravity access token, retaining the project id. */
export async function refreshAntigravity(refreshToken: string, projectId: string, clientSecret: string): Promise<OAuthCredentials> {
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();
+8 -8
View File
@@ -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<string, string> {
};
}
async function postForm(path: string, form: Record<string, string>): Promise<{ status: number; data: Record<string, unknown>; response: Response; text: string }> {
const response = await fetch(`${OAUTH_HOST}${path}`, {
async function postForm(path: string, form: Record<string, string>, cancel?: AbortSignal): Promise<{ status: number; data: Record<string, unknown>; 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<string, string>): 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<DeviceAuthorization> {
const { status, data } = await postForm("/api/oauth/device_authorization", { client_id: CLIENT_ID });
async function requestDeviceAuthorization(cancel?: AbortSignal): Promise<DeviceAuthorization> {
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<OAuthCreden
export const kimiCodeFlow: OAuthFlow = {
providerKey: "kimi-code",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
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.`,
+24
View File
@@ -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);
}
}
+8 -11
View File
@@ -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<OAuthCredentials> {
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 };
@@ -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<OAuthCredentials> {
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. */
@@ -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<str
const signature = base64url(createSign("RSA-SHA256").update(signingInput).sign(account.private_key));
const assertion = `${signingInput}.${signature}`;
const response = await fetch("https://oauth2.googleapis.com/token", {
const { response, text } = await oauthFetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
});
const text = await response.text();
if (!response.ok) throw httpFailure(`Vertex authentication failed: ${response.status} ${text}`, response, text);
const json = JSON.parse(text) as { access_token?: unknown; expires_in?: unknown };
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
@@ -10,6 +10,7 @@ import { AuthBrokerService } from "../../src/main/services/auth-broker-service";
import { IntegrationKeyStore } from "../../src/main/services/integration-key-store";
import { loadConfiguredProvider } from "../../src/main/services/provider-internal";
import { resolveTurnCredential } from "../../src/main/services/provider-tool-turns/credential-resolver";
import { stopRun, withRunControl } from "../../src/main/services/run-control";
const vault = { encryptSecret: (secret: string) => 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();
+41
View File
@@ -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}"]);
});
});