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:
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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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}"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user