fix(oauth): read pollToken body once on non-JSON upstream responses (kimi-coding, github) (#13046)

* fix(oauth): read pollToken body once on non-JSON upstream responses

The device-flow pollToken handlers for kimi-coding and github tried
response.json() first and fell back to response.text() in the catch.
Once .json() rejects on a non-JSON body the stream is already consumed,
so the .text() fallback always throws TypeError (Body is unusable) and
pollToken rejects, surfacing as a generic 500 on /api/oauth/<provider>/poll
instead of the intended graceful { error: "invalid_response" } payload.
Non-JSON responses are realistic when the OAuth upstream sits behind a
CDN/anti-bot HTML error page or a proxy interstitial (auth.kimi.com in
particular).

Read the body once as text, then JSON.parse it, preserving the original
invalid_response fallback. Adds a regression test that drives both
providers with a stubbed fetch returning an HTML error page and a JSON
error body. Prunes the two now-unused no-unused-vars suppressions for the
removed catch bindings.

* docs(changelog): fragment for #13046
This commit is contained in:
Tony Yu
2026-09-18 22:30:09 +08:00
committed by GitHub
parent bb198df737
commit 3d3f71f514
5 changed files with 75 additions and 16 deletions

View File

@@ -0,0 +1 @@
- fix(oauth): kimi-coding/github device-flow `pollToken` no longer rejects with `TypeError: Body is unusable` when the token endpoint returns a non-JSON error page (CDN/anti-bot/proxy interstitial) — the body is now read once and parsed, preserving the graceful `invalid_response` fallback instead of a generic 500 (#13046 — thanks @ysntony)

View File

@@ -1617,16 +1617,6 @@
"count": 1
}
},
"src/lib/oauth/providers/github.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/lib/oauth/providers/kimi-coding.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/lib/oauth/providers/kiro.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 3

View File

@@ -38,11 +38,14 @@ export const github = {
}),
});
// Read the body once: after a failed response.json() the stream is already
// consumed, so a fallback response.text() would throw "Body is unusable"
// and reject pollToken instead of surfacing the upstream error page.
const text = await response.text();
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = JSON.parse(text);
} catch {
data = { error: "invalid_response", error_description: text };
}

View File

@@ -98,11 +98,14 @@ export const kimiCoding = {
}),
});
// Read the body once: after a failed response.json() the stream is already
// consumed, so a fallback response.text() would throw "Body is unusable"
// and reject pollToken instead of surfacing the upstream error page.
const text = await response.text();
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = JSON.parse(text);
} catch {
data = { error: "invalid_response", error_description: text };
}

View File

@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import PROVIDERS_MAP from "../../src/lib/oauth/providers/index.ts";
import { GITHUB_CONFIG, KIMI_CODING_CONFIG } from "../../src/lib/oauth/constants/oauth.ts";
// Regression guard for the OAuth device-flow pollToken double body read.
//
// pollToken used to try `response.json()` first and fall back to
// `response.text()` in the catch. Once `.json()` rejects on a non-JSON body,
// the stream is already consumed, so the `.text()` fallback always throws
// `TypeError: Body is unusable` — rejecting pollToken and surfacing as a
// generic 500 on /api/oauth/<provider>/poll instead of the intended graceful
// `{ error: "invalid_response" }` payload. Non-JSON responses are realistic
// when the OAuth upstream sits behind a CDN/anti-bot HTML error page or a
// proxy interstitial (auth.kimi.com in particular).
//
// The guard drives the real provider modules with a stubbed global fetch.
function stubFetch(body: string, init?: ResponseInit) {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(body, init);
return () => {
globalThis.fetch = originalFetch;
};
}
const providers = [
{ id: "kimi-coding", config: KIMI_CODING_CONFIG },
{ id: "github", config: GITHUB_CONFIG },
] as const;
for (const { id, config } of providers) {
test(`${id} pollToken returns invalid_response (not a rejection) on non-JSON body`, async () => {
const restore = stubFetch("<html><body>502 Bad Gateway</body></html>", {
status: 502,
headers: { "content-type": "text/html" },
});
try {
const result = await PROVIDERS_MAP[id].pollToken(config, "device-code-stub");
assert.equal(result.ok, false);
assert.equal(result.data.error, "invalid_response");
assert.match(result.data.error_description, /502 Bad Gateway/);
} finally {
restore();
}
});
test(`${id} pollToken still parses JSON bodies`, async () => {
const restore = stubFetch(JSON.stringify({ error: "authorization_pending" }), {
status: 400,
headers: { "content-type": "application/json" },
});
try {
const result = await PROVIDERS_MAP[id].pollToken(config, "device-code-stub");
assert.equal(result.ok, false);
assert.equal(result.data.error, "authorization_pending");
} finally {
restore();
}
});
}