diff --git a/changelog.d/fixes/13046-oauth-polltoken-nonjson-body.md b/changelog.d/fixes/13046-oauth-polltoken-nonjson-body.md new file mode 100644 index 0000000000..e736d81b1b --- /dev/null +++ b/changelog.d/fixes/13046-oauth-polltoken-nonjson-body.md @@ -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) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index d966760de8..3952affee1 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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 diff --git a/src/lib/oauth/providers/github.ts b/src/lib/oauth/providers/github.ts index b3e47e1257..7412c15832 100644 --- a/src/lib/oauth/providers/github.ts +++ b/src/lib/oauth/providers/github.ts @@ -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 }; } diff --git a/src/lib/oauth/providers/kimi-coding.ts b/src/lib/oauth/providers/kimi-coding.ts index 3fbfc82c98..b49d65ae4d 100644 --- a/src/lib/oauth/providers/kimi-coding.ts +++ b/src/lib/oauth/providers/kimi-coding.ts @@ -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 }; } diff --git a/tests/unit/oauth-polltoken-nonjson-body.test.ts b/tests/unit/oauth-polltoken-nonjson-body.test.ts new file mode 100644 index 0000000000..b256734749 --- /dev/null +++ b/tests/unit/oauth-polltoken-nonjson-body.test.ts @@ -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//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("502 Bad Gateway", { + 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(); + } + }); +}