icebear0828 Claude Opus 4.6 commited on
Commit
abe58f1
·
1 Parent(s): 068f359

fix(auth): add direct-connection fallback for OAuth behind proxy

Browse files

When 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 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 curlFetchPost(
125
- config.auth.oauth_token_endpoint,
126
- "application/x-www-form-urlencoded",
127
- body.toString(),
 
 
 
 
128
  );
129
 
130
  if (!resp.ok) {
@@ -148,10 +155,14 @@ export async function refreshAccessToken(
148
  refresh_token: refreshToken,
149
  });
150
 
151
- const resp = await curlFetchPost(
152
- config.auth.oauth_token_endpoint,
153
- "application/x-www-form-urlencoded",
154
- body.toString(),
 
 
 
 
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 curlFetchPost(
346
- "https://auth.openai.com/oauth/device/code",
347
- "application/x-www-form-urlencoded",
348
- body.toString(),
 
 
 
 
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 curlFetchPost(
372
- config.auth.oauth_token_endpoint,
373
- "application/x-www-form-urlencoded",
374
- body.toString(),
 
 
 
 
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(url: string): Promise<CurlFetchResponse> {
 
 
 
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
+ }