mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 05:12:16 +03:00
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:
1
changelog.d/fixes/13046-oauth-polltoken-nonjson-body.md
Normal file
1
changelog.d/fixes/13046-oauth-polltoken-nonjson-body.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
62
tests/unit/oauth-polltoken-nonjson-body.test.ts
Normal file
62
tests/unit/oauth-polltoken-nonjson-body.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user