Files
OmniRoute/tests/unit/oauth-refresh-error-resilience.test.ts
Fouad Salkini fc6b240328 fix(oauth): classify an embedded invalid_grant in a refresh error body (#13466)
* fix(oauth): classify an embedded invalid_grant in a refresh error body

Cline answers a dead refresh_token with
400 {"data":"","error":"failed to refresh token: invalid_grant","success":false}
The code is the tail of a sentence — neither a bare code nor an
"error":"<code>" field pair — so extractOAuthErrorCode returned null.

A null classification means refreshClineToken emits no unrecoverable
sentinel, so a permanently consumed refresh_token is handled as a
TRANSIENT failure. tokenHealthCheck therefore never reaches its
unrecoverable branch, and never runs the credentialsChangedSinceSweep
race guard, the "please re-authenticate this account" message, or the
dead-token clear for rotating providers. The connection instead stays
active with errorCode "refresh_failed", retries the same consumed token
3x per sweep behind an exponential backoff, and 401s every request
routed to it indefinitely with no actionable operator signal.

Scan for a known unrecoverable code embedded in the error value as a
last resort, after the exact-match and nested-JSON paths, delimited on
both sides so server_error, xinvalid_grant, my_invalid_grant_flag and a
502 HTML page all still classify as null.

Also add cline to ROTATION_LOCK_GROUP: refreshClineToken reads a new
refreshToken out of every response body and a measured refresh rotated a
connection's stored token, so sibling connections must not refresh
concurrently. cline was already listed in tokenHealthCheck's
ROTATING_REFRESH_PROVIDERS but missing from the serializer.

* docs(changelog): add fragment for cline refresh token error classification

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 16:27:01 -03:00

245 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TDD — OAuth refresh error classification must be resilient to body SHAPE.
*
* Root cause of the production "1352× refresh loop" (claude/aa5dd5cf): when the
* Anthropic 400 body reaches `refreshClaudeOAuthToken` in a non-canonical shape
* (a JSON string instead of an object, a double-encoded string, a nested
* `{error:{code}}`, or the raw text in the catch branch), the old check
* `errorBody.error === "invalid_grant"` evaluated to false, so the function
* returned `null` instead of the `unrecoverable_refresh_error` sentinel.
*
* `null` makes the HealthCheck treat it as a recoverable failure → keeps the
* connection `active` → retries every 60s forever (the loop). The fix is a
* shape-agnostic extractor used by all refreshers that classify invalid_grant.
*
* These tests FAIL before the fix (functions return null) and pass after.
*/
import test from "node:test";
import assert from "node:assert/strict";
const tokenRefresh = await import("../../open-sse/services/tokenRefresh.ts");
const {
extractOAuthErrorCode,
refreshClaudeOAuthToken,
refreshClineToken,
refreshQoderToken,
refreshGitHubToken,
isUnrecoverableRefreshError,
} = tokenRefresh as unknown as {
extractOAuthErrorCode: (raw: unknown) => string | null;
refreshClaudeOAuthToken: (rt: string, log?: unknown, proxy?: unknown) => Promise<unknown>;
refreshClineToken: (rt: string, log?: unknown, proxy?: unknown) => Promise<unknown>;
refreshQoderToken: (rt: string, log?: unknown, proxy?: unknown) => Promise<unknown>;
refreshGitHubToken: (rt: string, log?: unknown, proxy?: unknown) => Promise<unknown>;
isUnrecoverableRefreshError: (r: unknown) => boolean;
};
function rawResponse(body: string, status = 400, contentType = "application/json") {
return new Response(body, { status, headers: { "content-type": contentType } });
}
async function withMockedFetch<T>(impl: typeof fetch, fn: () => Promise<T>): Promise<T> {
const original = globalThis.fetch;
globalThis.fetch = impl;
try {
return await fn();
} finally {
globalThis.fetch = original;
}
}
// ── extractOAuthErrorCode: shape matrix ─────────────────────────────────────
test("extractOAuthErrorCode: canonical object { error: 'invalid_grant' }", () => {
assert.equal(extractOAuthErrorCode({ error: "invalid_grant" }), "invalid_grant");
});
test("extractOAuthErrorCode: nested object { error: { code: 'invalid_grant' } }", () => {
assert.equal(extractOAuthErrorCode({ error: { code: "invalid_grant" } }), "invalid_grant");
});
test("extractOAuthErrorCode: bare string code 'invalid_grant'", () => {
assert.equal(extractOAuthErrorCode("invalid_grant"), "invalid_grant");
});
test('extractOAuthErrorCode: JSON string body \'{"error":"invalid_grant"}\'', () => {
assert.equal(extractOAuthErrorCode('{"error": "invalid_grant"}'), "invalid_grant");
});
test("extractOAuthErrorCode: double-encoded JSON string (the production case)", () => {
// response.json() returned the inner JSON AS a string (proxy double-encode)
const doubleEncoded = JSON.stringify('{"error": "invalid_grant", "error_description": "x"}');
const parsedOnce = JSON.parse(doubleEncoded); // a string, still JSON inside
assert.equal(extractOAuthErrorCode(parsedOnce), "invalid_grant");
});
test("extractOAuthErrorCode: catch-branch shape { error: '<raw json text>' }", () => {
// refreshClaudeOAuthToken's catch did errorBody = { error: text }
const errorBody = { error: '{"error": "invalid_grant", "error_description": "x"}' };
assert.equal(extractOAuthErrorCode(errorBody), "invalid_grant");
});
test("extractOAuthErrorCode: invalid_request is recognized", () => {
assert.equal(extractOAuthErrorCode({ error: "invalid_request" }), "invalid_request");
});
test("extractOAuthErrorCode: transient errors are NOT misclassified (no false positives)", () => {
assert.equal(extractOAuthErrorCode({ error: "server_error" }), null);
assert.equal(extractOAuthErrorCode("rate_limited"), null);
assert.equal(extractOAuthErrorCode("<!DOCTYPE html><html>502 Bad Gateway</html>"), null);
assert.equal(extractOAuthErrorCode(""), null);
assert.equal(extractOAuthErrorCode(null), null);
assert.equal(extractOAuthErrorCode(undefined), null);
});
// ── code EMBEDDED in a message, not returned bare ───────────────────────────
// Cline's refresh endpoint answers a dead refresh_token with
// 400 {"data":"","error":"failed to refresh token: invalid_grant","success":false}
// The code is the tail of a sentence, so the exact-match Set lookup and the
// `"error":"<code>"` field scan both miss it. It classified as null → TRANSIENT
// → the connection was retried forever instead of prompting a re-auth.
test("extractOAuthErrorCode: code embedded in a message (the Cline production body)", () => {
assert.equal(
extractOAuthErrorCode({ error: "failed to refresh token: invalid_grant" }),
"invalid_grant"
);
// Same body as the raw text the catch branch forwards.
assert.equal(
extractOAuthErrorCode(
'{"data":"","error":"failed to refresh token: invalid_grant","success":false}'
),
"invalid_grant"
);
});
test("extractOAuthErrorCode: embedded match is word-delimited (no substring false positives)", () => {
// `_` is a word character, so a code glued to other identifier chars must NOT match.
assert.equal(extractOAuthErrorCode({ error: "xinvalid_grant" }), null);
assert.equal(extractOAuthErrorCode({ error: "invalid_grantx" }), null);
assert.equal(extractOAuthErrorCode({ error: "my_invalid_grant_flag" }), null);
// A transient message that happens to contain no known code stays transient.
assert.equal(extractOAuthErrorCode({ error: "upstream timed out after 30s" }), null);
// Punctuation and whitespace ARE valid delimiters.
assert.equal(extractOAuthErrorCode({ error: "token rejected (invalid_grant)" }), "invalid_grant");
});
// ── refreshClaudeOAuthToken: every shape → unrecoverable sentinel ────────────
const SENTINEL_SHAPES: Array<{ name: string; body: string; ct?: string }> = [
{
name: "canonical object",
body: '{"error": "invalid_grant", "error_description": "Refresh token not found or invalid"}',
},
{
name: "double-encoded JSON string",
body: JSON.stringify('{"error": "invalid_grant", "error_description": "x"}'),
},
{ name: "bare string code", body: '"invalid_grant"' },
{ name: "nested error.code", body: '{"error": {"code": "invalid_grant", "message": "x"}}' },
{ name: "json served as text/plain", body: '{"error": "invalid_grant"}', ct: "text/plain" },
];
for (const shape of SENTINEL_SHAPES) {
test(`refreshClaudeOAuthToken → unrecoverable sentinel for shape: ${shape.name}`, async () => {
await withMockedFetch(
(async () =>
rawResponse(shape.body, 400, shape.ct ?? "application/json")) as unknown as typeof fetch,
async () => {
const result = await refreshClaudeOAuthToken("dead-refresh-token");
assert.ok(
isUnrecoverableRefreshError(result),
`shape "${shape.name}" must yield an unrecoverable sentinel, got ${JSON.stringify(result)}`
);
assert.equal((result as { code?: string }).code, "invalid_grant");
}
);
});
}
test("refreshClaudeOAuthToken: transient 500 server_error stays null (NOT unrecoverable)", async () => {
await withMockedFetch(
(async () => rawResponse('{"error": "server_error"}', 500)) as unknown as typeof fetch,
async () => {
const result = await refreshClaudeOAuthToken("token");
assert.equal(
result,
null,
"transient errors must remain recoverable (null), not deactivate the account"
);
}
);
});
test("refreshClaudeOAuthToken: 502 HTML gateway error stays null", async () => {
await withMockedFetch(
(async () =>
rawResponse("<html>502 Bad Gateway</html>", 502, "text/html")) as unknown as typeof fetch,
async () => {
const result = await refreshClaudeOAuthToken("token");
assert.equal(result, null);
}
);
});
// ── Previously-frágil refreshers that NEVER emitted a sentinel ──────────────
// refreshClineToken / refreshQoderToken / refreshGitHubToken returned null on
// ANY error → invalid_grant looked recoverable → HealthCheck refresh loop.
// Note: refreshQoderToken also got the same fix, but it early-returns null via a
// config guard (no clientId/secret in the test env) so it can't be exercised here.
const FRAGILE_REFRESHERS: Array<{
name: string;
fn: (rt: string) => Promise<unknown>;
}> = [
{ name: "refreshClineToken", fn: (rt) => refreshClineToken(rt) },
{ name: "refreshGitHubToken", fn: (rt) => refreshGitHubToken(rt) },
];
void refreshQoderToken; // fixed in source; not unit-testable without OAuth config
for (const r of FRAGILE_REFRESHERS) {
test(`${r.name}: invalid_grant now yields an unrecoverable sentinel`, async () => {
await withMockedFetch(
(async () => rawResponse('{"error": "invalid_grant"}', 400)) as unknown as typeof fetch,
async () => {
const result = await r.fn("dead-token");
assert.ok(
isUnrecoverableRefreshError(result),
`${r.name} must classify invalid_grant as unrecoverable, got ${JSON.stringify(result)}`
);
}
);
});
test(`${r.name}: transient 500 server_error stays null`, async () => {
await withMockedFetch(
(async () => rawResponse('{"error": "server_error"}', 500)) as unknown as typeof fetch,
async () => {
const result = await r.fn("token");
assert.equal(result, null, `${r.name} must keep transient errors recoverable`);
}
);
});
}
// Cline's REAL production 400 body — the code arrives inside a sentence, so
// before the embedded-code scan this returned null and the HealthCheck treated a
// permanently consumed refresh_token as a retryable blip.
test("refreshClineToken: the verbatim production body yields an unrecoverable sentinel", async () => {
await withMockedFetch(
(async () =>
rawResponse(
'{"data":"","error":"failed to refresh token: invalid_grant","success":false}\n',
400
)) as unknown as typeof fetch,
async () => {
const result = await refreshClineToken("consumed-token");
assert.ok(
isUnrecoverableRefreshError(result),
`expected unrecoverable sentinel, got ${JSON.stringify(result)}`
);
}
);
});