Skip to content

Commit e1b4793

Browse files
CTX7-1839: Surface the real error when a CLI OAuth request fails (#2915)
1 parent eb7ac50 commit e1b4793

4 files changed

Lines changed: 166 additions & 35 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ctx7": patch
3+
---
4+
5+
Surface the underlying network error when an OAuth request fails. Connection failures now report the cause (TLS interception, DNS, firewall, timeout) with a hint, and non-JSON error responses report the HTTP status and body excerpt instead of a generic message.

packages/cli/src/__tests__/auth-utils.test.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,38 @@ describe("getValidAccessToken", () => {
299299
"fetch",
300300
vi.fn().mockResolvedValue({
301301
ok: false,
302-
json: () => Promise.resolve({ error: "invalid_grant" }),
302+
status: 400,
303+
url: "https://test.context7.com/api/oauth/token",
304+
text: () => Promise.resolve(JSON.stringify({ error: "invalid_grant" })),
303305
})
304306
);
305307

306308
expect(await getValidAccessToken()).toBeNull();
307309
});
310+
311+
// An expired refresh token is indistinguishable from being logged out, so the
312+
// caller reports "not logged in" rather than surfacing a network error here.
313+
test("returns null when the refresh connection fails", async () => {
314+
const tokens: TokenData = {
315+
access_token: "expired-tok",
316+
token_type: "bearer",
317+
expires_at: Date.now() - 1000,
318+
refresh_token: "refresh-tok",
319+
};
320+
321+
mfs.existsSync.mockReturnValue(true);
322+
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
323+
vi.stubGlobal(
324+
"fetch",
325+
vi
326+
.fn()
327+
.mockRejectedValue(
328+
Object.assign(new TypeError("fetch failed"), { cause: { code: "ENOTFOUND" } })
329+
)
330+
);
331+
332+
expect(await getValidAccessToken()).toBeNull();
333+
});
308334
});
309335

310336
describe("startDeviceAuthorization", () => {
@@ -354,12 +380,45 @@ describe("startDeviceAuthorization", () => {
354380
"fetch",
355381
vi.fn().mockResolvedValue({
356382
ok: false,
357-
json: () =>
358-
Promise.resolve({ error: "invalid_request", error_description: "bad client_id" }),
383+
status: 400,
384+
url: "https://t/api/oauth/device/code",
385+
text: () =>
386+
Promise.resolve(
387+
JSON.stringify({ error: "invalid_request", error_description: "bad client_id" })
388+
),
359389
})
360390
);
361391
await expect(startDeviceAuthorization("https://t", "bogus")).rejects.toThrow("bad client_id");
362392
});
393+
394+
test("reports status and body excerpt when the response is not JSON", async () => {
395+
vi.stubGlobal(
396+
"fetch",
397+
vi.fn().mockResolvedValue({
398+
ok: false,
399+
status: 403,
400+
url: "https://t/api/oauth/device/code",
401+
text: () => Promise.resolve("<html><body>Blocked by proxy</body></html>"),
402+
})
403+
);
404+
await expect(startDeviceAuthorization("https://t", "c")).rejects.toThrow(
405+
/HTTP 403.*Blocked by proxy/s
406+
);
407+
});
408+
409+
test("surfaces the underlying cause when the connection fails", async () => {
410+
const failure = Object.assign(new TypeError("fetch failed"), {
411+
cause: {
412+
code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
413+
message: "unable to verify leaf signature",
414+
},
415+
});
416+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(failure));
417+
418+
await expect(startDeviceAuthorization("https://t", "c")).rejects.toThrow(
419+
/UNABLE_TO_VERIFY_LEAF_SIGNATURE.*NODE_EXTRA_CA_CERTS/s
420+
);
421+
});
363422
});
364423

365424
describe("pollDeviceToken", () => {
@@ -412,7 +471,7 @@ describe("pollDeviceToken", () => {
412471
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
413472
const result = await pollDeviceToken("https://t", "c", "dc");
414473
expect(result.status).toBe("transient");
415-
expect(result.errorMessage).toBe("ECONNREFUSED");
474+
expect(result.errorMessage).toContain("ECONNREFUSED");
416475
});
417476

418477
test("throws on unknown 4xx error code", async () => {

packages/cli/src/utils/auth.ts

Lines changed: 96 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,15 @@ export function isTokenExpired(tokens: TokenData): boolean {
8585
}
8686

8787
async function refreshAccessToken(refreshToken: string): Promise<TokenData> {
88-
const response = await fetch(`${getBaseUrl()}/api/oauth/token`, {
89-
method: "POST",
90-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
91-
body: new URLSearchParams({
88+
return oauthRequest<TokenData>(
89+
`${getBaseUrl()}/api/oauth/token`,
90+
new URLSearchParams({
9291
grant_type: "refresh_token",
9392
client_id: CLI_CLIENT_ID,
9493
refresh_token: refreshToken,
95-
}).toString(),
96-
});
97-
98-
if (!response.ok) {
99-
const err = (await response.json().catch(() => ({}))) as TokenErrorResponse;
100-
throw new Error(err.error_description || err.error || "Failed to refresh token");
101-
}
102-
103-
return (await response.json()) as TokenData;
94+
}),
95+
"Failed to refresh token"
96+
);
10497
}
10598

10699
/**
@@ -147,6 +140,86 @@ export interface DeviceAuthorizationResponse {
147140

148141
const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
149142

143+
async function describeErrorResponse(response: Response, fallback: string): Promise<string> {
144+
const body = await response.text().catch(() => "");
145+
146+
try {
147+
const err = JSON.parse(body) as TokenErrorResponse;
148+
const message = err.error_description || err.error;
149+
if (message) return message;
150+
} catch {
151+
// An interceptor's HTML, not an OAuth error object.
152+
}
153+
154+
const excerpt = body.replace(/\s+/g, " ").trim().slice(0, 200);
155+
const detail = `HTTP ${response.status} from ${response.url}`;
156+
return excerpt ? `${fallback} (${detail}): ${excerpt}` : `${fallback} (${detail})`;
157+
}
158+
159+
const TLS_HINT =
160+
"The TLS certificate could not be verified, which usually means a proxy is inspecting HTTPS traffic. Point NODE_EXTRA_CA_CERTS at your organization's root CA.";
161+
const DNS_HINT = "DNS lookup failed. Check your network or VPN connection.";
162+
const BLOCKED_HINT =
163+
"The connection was refused or reset, which usually means a firewall or proxy is blocking it.";
164+
const TIMEOUT_HINT = "The connection timed out. A proxy or firewall may be dropping the request.";
165+
const DEFAULT_HINT =
166+
"If you are behind a corporate proxy, note that Node does not use HTTPS_PROXY automatically.";
167+
168+
const CONNECTION_HINTS: Record<string, string> = {
169+
UNABLE_TO_VERIFY_LEAF_SIGNATURE: TLS_HINT,
170+
SELF_SIGNED_CERT_IN_CHAIN: TLS_HINT,
171+
DEPTH_ZERO_SELF_SIGNED_CERT: TLS_HINT,
172+
CERT_HAS_EXPIRED: TLS_HINT,
173+
ENOTFOUND: DNS_HINT,
174+
EAI_AGAIN: DNS_HINT,
175+
ECONNREFUSED: BLOCKED_HINT,
176+
ECONNRESET: BLOCKED_HINT,
177+
EHOSTUNREACH: BLOCKED_HINT,
178+
ENETUNREACH: BLOCKED_HINT,
179+
UND_ERR_CONNECT_TIMEOUT: TIMEOUT_HINT,
180+
ETIMEDOUT: TIMEOUT_HINT,
181+
};
182+
183+
function getErrorCause(error: unknown): { code?: string; message?: string } {
184+
if (typeof error !== "object" || error === null || !("cause" in error)) return {};
185+
const cause = (error as { cause: unknown }).cause;
186+
if (typeof cause !== "object" || cause === null) return {};
187+
188+
const { code, message } = cause as { code?: unknown; message?: unknown };
189+
return {
190+
code: typeof code === "string" ? code : undefined,
191+
message: typeof message === "string" ? message : undefined,
192+
};
193+
}
194+
195+
function describeConnectionError(error: unknown, url: string): string {
196+
const { code, message } = getErrorCause(error);
197+
const detail = message || (error instanceof Error ? error.message : String(error));
198+
const hint = (code && CONNECTION_HINTS[code]) || DEFAULT_HINT;
199+
200+
return `Could not reach ${url}: ${detail}${code ? ` (${code})` : ""}\n${hint}`;
201+
}
202+
203+
async function postForm(url: string, params: URLSearchParams): Promise<Response> {
204+
try {
205+
return await fetch(url, {
206+
method: "POST",
207+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
208+
body: params.toString(),
209+
});
210+
} catch (error) {
211+
throw new Error(describeConnectionError(error, url));
212+
}
213+
}
214+
215+
async function oauthRequest<T>(url: string, params: URLSearchParams, fallback: string): Promise<T> {
216+
const response = await postForm(url, params);
217+
if (!response.ok) {
218+
throw new Error(await describeErrorResponse(response, fallback));
219+
}
220+
return (await response.json()) as T;
221+
}
222+
150223
/** RFC 8628 §3.2 default poll interval when the server omits `interval`. */
151224
export const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 5;
152225

@@ -165,18 +238,11 @@ export async function startDeviceAuthorization(
165238
// ignore
166239
}
167240

168-
const response = await fetch(`${baseUrl}/api/oauth/device/code`, {
169-
method: "POST",
170-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
171-
body: params.toString(),
172-
});
173-
174-
if (!response.ok) {
175-
const err = (await response.json().catch(() => ({}))) as TokenErrorResponse;
176-
throw new Error(err.error_description || err.error || "Failed to start device authorization");
177-
}
178-
179-
return (await response.json()) as DeviceAuthorizationResponse;
241+
return oauthRequest<DeviceAuthorizationResponse>(
242+
`${baseUrl}/api/oauth/device/code`,
243+
params,
244+
"Failed to start device authorization"
245+
);
180246
}
181247

182248
export interface PollDeviceTokenResult {
@@ -192,15 +258,14 @@ export async function pollDeviceToken(
192258
): Promise<PollDeviceTokenResult> {
193259
let response: Response;
194260
try {
195-
response = await fetch(`${baseUrl}/api/oauth/device/token`, {
196-
method: "POST",
197-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
198-
body: new URLSearchParams({
261+
response = await postForm(
262+
`${baseUrl}/api/oauth/device/token`,
263+
new URLSearchParams({
199264
grant_type: DEVICE_CODE_GRANT,
200265
device_code: deviceCode,
201266
client_id: clientId,
202-
}).toString(),
203-
});
267+
})
268+
);
204269
} catch (error) {
205270
// Network blip — keep polling.
206271
return {

packages/sdk/vitest.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export default defineConfig({
1010
environment: "node",
1111
include: ["src/**/*.test.ts"],
1212
env: process.env,
13+
// These tests call the live API, so the 5s default fails on latency alone.
14+
testTimeout: 30_000,
1315
},
1416
resolve: {
1517
alias: {

0 commit comments

Comments
 (0)