Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
abe58f1
1
Parent(s): 068f359
fix(auth): add direct-connection fallback for OAuth behind proxy
Browse filesWhen a global proxy is configured, OAuth requests to auth.openai.com can
fail due to Cloudflare challenges (403) or TLS/schannel errors. Since
auth endpoints are not fingerprint-sensitive, we safely retry with a
direct connection on these failures.
- Add generic withDirectFallback() wrapper (src/tls/direct-fallback.ts)
- Add proxyUrl pass-through to curlFetchGet/curlFetchPost
- Fix Accept-Encoding for system curl (strip br/zstd like codex-api.ts)
- Wrap all 4 OAuth functions with direct-connection fallback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/auth/oauth-pkce.ts +36 -17
- src/tls/curl-fetch.ts +18 -3
- src/tls/direct-fallback.ts +78 -0
src/auth/oauth-pkce.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { readFileSync, existsSync } from "fs";
|
|
| 9 |
import { resolve } from "path";
|
| 10 |
import { homedir } from "os";
|
| 11 |
import { getConfig } from "../config.js";
|
| 12 |
-
import { curlFetchPost } from "../tls/curl-fetch.js";
|
|
|
|
| 13 |
|
| 14 |
export interface PKCEChallenge {
|
| 15 |
codeVerifier: string;
|
|
@@ -41,6 +42,8 @@ interface PendingSession {
|
|
| 41 |
createdAt: number;
|
| 42 |
}
|
| 43 |
|
|
|
|
|
|
|
| 44 |
/** In-memory store for pending OAuth sessions, keyed by `state`. */
|
| 45 |
const pendingSessions = new Map<string, PendingSession>();
|
| 46 |
|
|
@@ -121,10 +124,14 @@ export async function exchangeCode(
|
|
| 121 |
code_verifier: codeVerifier,
|
| 122 |
});
|
| 123 |
|
| 124 |
-
const resp = await
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
);
|
| 129 |
|
| 130 |
if (!resp.ok) {
|
|
@@ -148,10 +155,14 @@ export async function refreshAccessToken(
|
|
| 148 |
refresh_token: refreshToken,
|
| 149 |
});
|
| 150 |
|
| 151 |
-
const resp = await
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
);
|
| 156 |
|
| 157 |
if (!resp.ok) {
|
|
@@ -342,10 +353,14 @@ export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
|
|
| 342 |
scope: "openid profile email offline_access",
|
| 343 |
});
|
| 344 |
|
| 345 |
-
const resp = await
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
);
|
| 350 |
|
| 351 |
if (!resp.ok) {
|
|
@@ -368,10 +383,14 @@ export async function pollDeviceToken(deviceCode: string): Promise<TokenResponse
|
|
| 368 |
client_id: config.auth.oauth_client_id,
|
| 369 |
});
|
| 370 |
|
| 371 |
-
const resp = await
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
);
|
| 376 |
|
| 377 |
if (!resp.ok) {
|
|
|
|
| 9 |
import { resolve } from "path";
|
| 10 |
import { homedir } from "os";
|
| 11 |
import { getConfig } from "../config.js";
|
| 12 |
+
import { curlFetchPost, type CurlFetchResponse } from "../tls/curl-fetch.js";
|
| 13 |
+
import { withDirectFallback, isCloudflareChallengeResponse } from "../tls/direct-fallback.js";
|
| 14 |
|
| 15 |
export interface PKCEChallenge {
|
| 16 |
codeVerifier: string;
|
|
|
|
| 42 |
createdAt: number;
|
| 43 |
}
|
| 44 |
|
| 45 |
+
const isCfResponse = (r: CurlFetchResponse) => isCloudflareChallengeResponse(r.status, r.body);
|
| 46 |
+
|
| 47 |
/** In-memory store for pending OAuth sessions, keyed by `state`. */
|
| 48 |
const pendingSessions = new Map<string, PendingSession>();
|
| 49 |
|
|
|
|
| 124 |
code_verifier: codeVerifier,
|
| 125 |
});
|
| 126 |
|
| 127 |
+
const resp = await withDirectFallback(
|
| 128 |
+
(proxyUrl) => curlFetchPost(
|
| 129 |
+
config.auth.oauth_token_endpoint,
|
| 130 |
+
"application/x-www-form-urlencoded",
|
| 131 |
+
body.toString(),
|
| 132 |
+
{ proxyUrl },
|
| 133 |
+
),
|
| 134 |
+
{ tag: "OAuth/exchangeCode", shouldFallback: isCfResponse },
|
| 135 |
);
|
| 136 |
|
| 137 |
if (!resp.ok) {
|
|
|
|
| 155 |
refresh_token: refreshToken,
|
| 156 |
});
|
| 157 |
|
| 158 |
+
const resp = await withDirectFallback(
|
| 159 |
+
(proxyUrl) => curlFetchPost(
|
| 160 |
+
config.auth.oauth_token_endpoint,
|
| 161 |
+
"application/x-www-form-urlencoded",
|
| 162 |
+
body.toString(),
|
| 163 |
+
{ proxyUrl },
|
| 164 |
+
),
|
| 165 |
+
{ tag: "OAuth/refresh", shouldFallback: isCfResponse },
|
| 166 |
);
|
| 167 |
|
| 168 |
if (!resp.ok) {
|
|
|
|
| 353 |
scope: "openid profile email offline_access",
|
| 354 |
});
|
| 355 |
|
| 356 |
+
const resp = await withDirectFallback(
|
| 357 |
+
(proxyUrl) => curlFetchPost(
|
| 358 |
+
"https://auth.openai.com/oauth/device/code",
|
| 359 |
+
"application/x-www-form-urlencoded",
|
| 360 |
+
body.toString(),
|
| 361 |
+
{ proxyUrl },
|
| 362 |
+
),
|
| 363 |
+
{ tag: "OAuth/deviceCode", shouldFallback: isCfResponse },
|
| 364 |
);
|
| 365 |
|
| 366 |
if (!resp.ok) {
|
|
|
|
| 383 |
client_id: config.auth.oauth_client_id,
|
| 384 |
});
|
| 385 |
|
| 386 |
+
const resp = await withDirectFallback(
|
| 387 |
+
(proxyUrl) => curlFetchPost(
|
| 388 |
+
config.auth.oauth_token_endpoint,
|
| 389 |
+
"application/x-www-form-urlencoded",
|
| 390 |
+
body.toString(),
|
| 391 |
+
{ proxyUrl },
|
| 392 |
+
),
|
| 393 |
+
{ tag: "OAuth/pollDevice", shouldFallback: isCfResponse },
|
| 394 |
);
|
| 395 |
|
| 396 |
if (!resp.ok) {
|
src/tls/curl-fetch.ts
CHANGED
|
@@ -17,14 +17,25 @@ export interface CurlFetchResponse {
|
|
| 17 |
ok: boolean;
|
| 18 |
}
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
/**
|
| 21 |
* Perform a GET request via the TLS transport.
|
| 22 |
*/
|
| 23 |
-
export async function curlFetchGet(
|
|
|
|
|
|
|
|
|
|
| 24 |
const transport = getTransport();
|
| 25 |
const headers = buildAnonymousHeaders();
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
const result = await transport.get(url, headers, 30);
|
| 28 |
return {
|
| 29 |
status: result.status,
|
| 30 |
body: result.body,
|
|
@@ -39,12 +50,16 @@ export async function curlFetchPost(
|
|
| 39 |
url: string,
|
| 40 |
contentType: string,
|
| 41 |
body: string,
|
|
|
|
| 42 |
): Promise<CurlFetchResponse> {
|
| 43 |
const transport = getTransport();
|
| 44 |
const headers = buildAnonymousHeaders();
|
|
|
|
|
|
|
|
|
|
| 45 |
headers["Content-Type"] = contentType;
|
| 46 |
|
| 47 |
-
const result = await transport.simplePost(url, headers, body, 30);
|
| 48 |
return {
|
| 49 |
status: result.status,
|
| 50 |
body: result.body,
|
|
|
|
| 17 |
ok: boolean;
|
| 18 |
}
|
| 19 |
|
| 20 |
+
export interface CurlFetchOptions {
|
| 21 |
+
/** Proxy override: undefined = global default, null = direct, string = specific. */
|
| 22 |
+
proxyUrl?: string | null;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
/**
|
| 26 |
* Perform a GET request via the TLS transport.
|
| 27 |
*/
|
| 28 |
+
export async function curlFetchGet(
|
| 29 |
+
url: string,
|
| 30 |
+
options?: CurlFetchOptions,
|
| 31 |
+
): Promise<CurlFetchResponse> {
|
| 32 |
const transport = getTransport();
|
| 33 |
const headers = buildAnonymousHeaders();
|
| 34 |
+
if (!transport.isImpersonate()) {
|
| 35 |
+
headers["Accept-Encoding"] = "gzip, deflate";
|
| 36 |
+
}
|
| 37 |
|
| 38 |
+
const result = await transport.get(url, headers, 30, options?.proxyUrl);
|
| 39 |
return {
|
| 40 |
status: result.status,
|
| 41 |
body: result.body,
|
|
|
|
| 50 |
url: string,
|
| 51 |
contentType: string,
|
| 52 |
body: string,
|
| 53 |
+
options?: CurlFetchOptions,
|
| 54 |
): Promise<CurlFetchResponse> {
|
| 55 |
const transport = getTransport();
|
| 56 |
const headers = buildAnonymousHeaders();
|
| 57 |
+
if (!transport.isImpersonate()) {
|
| 58 |
+
headers["Accept-Encoding"] = "gzip, deflate";
|
| 59 |
+
}
|
| 60 |
headers["Content-Type"] = contentType;
|
| 61 |
|
| 62 |
+
const result = await transport.simplePost(url, headers, body, 30, options?.proxyUrl);
|
| 63 |
return {
|
| 64 |
status: result.status,
|
| 65 |
body: result.body,
|
src/tls/direct-fallback.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Direct-connection fallback for proxied requests.
|
| 3 |
+
*
|
| 4 |
+
* When a global proxy is configured, some endpoints (e.g. auth.openai.com)
|
| 5 |
+
* may reject the proxy IP via Cloudflare challenge or cause TLS errors.
|
| 6 |
+
* This module provides a generic wrapper that retries with a direct connection.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { getProxyUrl } from "./curl-binary.js";
|
| 10 |
+
|
| 11 |
+
/** Detect if an HTTP response is a Cloudflare challenge page. */
|
| 12 |
+
export function isCloudflareChallengeResponse(status: number, body: string): boolean {
|
| 13 |
+
if (status !== 403) return false;
|
| 14 |
+
const lower = body.toLowerCase();
|
| 15 |
+
return (
|
| 16 |
+
lower.includes("cf-mitigated") ||
|
| 17 |
+
lower.includes("cf-chl-bypass") ||
|
| 18 |
+
lower.includes("_cf_chl") ||
|
| 19 |
+
lower.includes("attention required") ||
|
| 20 |
+
lower.includes("just a moment")
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** Detect if an error is a proxy/TLS network failure worth retrying direct. */
|
| 25 |
+
export function isProxyNetworkError(error: unknown): boolean {
|
| 26 |
+
const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
| 27 |
+
return (
|
| 28 |
+
msg.includes("econnreset") ||
|
| 29 |
+
msg.includes("econnrefused") ||
|
| 30 |
+
msg.includes("ssl_error_syscall") ||
|
| 31 |
+
msg.includes("schannel") ||
|
| 32 |
+
msg.includes("connection reset by peer") ||
|
| 33 |
+
msg.includes("socket hang up") ||
|
| 34 |
+
msg.includes("curl exited with code 35") || // TLS handshake failure
|
| 35 |
+
msg.includes("curl exited with code 56") // network receive error
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export interface DirectFallbackOptions<T> {
|
| 40 |
+
/** Label for log messages. */
|
| 41 |
+
tag?: string;
|
| 42 |
+
/** Check if a successful (non-thrown) result should trigger fallback (e.g. CF 403). */
|
| 43 |
+
shouldFallback?: (result: T) => boolean;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* Execute an async operation with automatic direct-connection fallback.
|
| 48 |
+
*
|
| 49 |
+
* The callback receives `proxyUrl`:
|
| 50 |
+
* - First call: `undefined` (use global proxy default)
|
| 51 |
+
* - Fallback call: `null` (force direct, bypass proxy)
|
| 52 |
+
*
|
| 53 |
+
* If no global proxy is configured, runs once with no fallback.
|
| 54 |
+
*/
|
| 55 |
+
export async function withDirectFallback<T>(
|
| 56 |
+
fn: (proxyUrl: string | null | undefined) => Promise<T>,
|
| 57 |
+
options?: DirectFallbackOptions<T>,
|
| 58 |
+
): Promise<T> {
|
| 59 |
+
const label = options?.tag ?? "DirectFallback";
|
| 60 |
+
const hasProxy = getProxyUrl() !== null;
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
const result = await fn(undefined);
|
| 64 |
+
|
| 65 |
+
if (hasProxy && options?.shouldFallback?.(result)) {
|
| 66 |
+
console.warn(`[${label}] Cloudflare challenge via proxy, retrying direct`);
|
| 67 |
+
return await fn(null);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
return result;
|
| 71 |
+
} catch (err) {
|
| 72 |
+
if (hasProxy && isProxyNetworkError(err)) {
|
| 73 |
+
console.warn(`[${label}] Network/TLS error via proxy, retrying direct`);
|
| 74 |
+
return await fn(null);
|
| 75 |
+
}
|
| 76 |
+
throw err;
|
| 77 |
+
}
|
| 78 |
+
}
|