mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 12:52:25 +03:00
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>
This commit is contained in:
1
changelog.d/fixes/cline-refresh-token-classification.md
Normal file
1
changelog.d/fixes/cline-refresh-token-classification.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(oauth):** classify embedded `invalid_grant` in Cline token refresh error bodies so permanently consumed refresh tokens trigger re-authentication instead of indefinite transient retry loops, and add `cline` to `ROTATION_LOCK_GROUP` to serialize concurrent sibling refreshes ([#13466](https://github.com/diegosouzapw/OmniRoute/pull/13466))
|
||||
@@ -27,6 +27,12 @@ const ROTATION_LOCK_GROUP: Record<string, string> = {
|
||||
"gitlab-duo": "gitlab-duo",
|
||||
kiro: "kiro",
|
||||
"kimi-coding": "kimi-coding",
|
||||
// Cline rotates on every refresh — `refreshClineToken` reads a new
|
||||
// `refreshToken` out of the response body, and a measured refresh moved a
|
||||
// connection's stored token to a different value. It was missing here while
|
||||
// already listed in tokenHealthCheck's ROTATING_REFRESH_PROVIDERS, so sibling
|
||||
// connections could refresh concurrently and present superseded tokens.
|
||||
cline: "cline",
|
||||
};
|
||||
|
||||
// Protective settle gap (ms) between two consecutive sibling refreshes when the
|
||||
|
||||
@@ -41,6 +41,17 @@ const UNRECOVERABLE_OAUTH_ERROR_CODES = new Set([
|
||||
"access_denied",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Matches a known unrecoverable code EMBEDDED in a human-readable message, on
|
||||
* word boundaries (`_` counts as a word char, so `xinvalid_grant` never hits).
|
||||
* Alternation is safe against overlap because every candidate is delimited on
|
||||
* both sides. Built once — the code set is a module constant.
|
||||
*/
|
||||
const EMBEDDED_OAUTH_ERROR_CODE_RE = new RegExp(
|
||||
`(?<![0-9a-z_])(${Array.from(UNRECOVERABLE_OAUTH_ERROR_CODES).join("|")})(?![0-9a-z_])`,
|
||||
"i"
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract a canonical OAuth error code from a refresh-endpoint error body of
|
||||
* ANY shape. Production proxies/MITMs deliver the same `invalid_grant` 400 in
|
||||
@@ -51,10 +62,24 @@ const UNRECOVERABLE_OAUTH_ERROR_CODES = new Set([
|
||||
* so the others returned `null` → the HealthCheck refresh loop (root cause of
|
||||
* the 1352× claude/aa5dd5cf invalidation storm).
|
||||
*
|
||||
* Some providers do not return the code bare at all, but as the tail of a
|
||||
* sentence: Cline answers a dead refresh_token with
|
||||
* `{"error":"failed to refresh token: invalid_grant"}`. That is neither an
|
||||
* exact code nor a `"error":"<code>"` field pair, so it used to return `null`
|
||||
* and a permanently dead token was classified as a TRANSIENT failure — the
|
||||
* unrecoverable branch in `tokenHealthCheck` (its `credentialsChangedSinceSweep`
|
||||
* race guard, the "please re-authenticate" message, and the dead-token clear for
|
||||
* rotating providers) never ran, so the token was retried forever and every
|
||||
* request routed to that connection 401'd with no actionable signal.
|
||||
*
|
||||
* Returns the matched code (only if it is in UNRECOVERABLE_OAUTH_ERROR_CODES)
|
||||
* or null. Never matches loosely — a known code is accepted only when it is a
|
||||
* bare code string or the value of an `"error"`/`"error_code"` field, so a 502
|
||||
* HTML page or a `server_error` body never becomes a false positive.
|
||||
* or null. Matching stays conservative: a known code is accepted only as a bare
|
||||
* code string, as the value of an `"error"`/`"error_code"` field, or as a
|
||||
* word-delimited token inside such a value — so a `server_error` body or a 502
|
||||
* HTML page still classifies as null. The deliberate trade-off is that a
|
||||
* message which merely MENTIONS a dead-token code is treated as unrecoverable;
|
||||
* that fails safe, because the alternative is an unbounded retry loop that
|
||||
* burns a rotating provider's refresh tokens.
|
||||
*/
|
||||
export function extractOAuthErrorCode(raw: unknown, depth = 0): string | null {
|
||||
if (raw == null || depth > 6) return null;
|
||||
@@ -76,6 +101,10 @@ export function extractOAuthErrorCode(raw: unknown, depth = 0): string | null {
|
||||
// field inside otherwise-unparsed text. Scoped to avoid false positives.
|
||||
const m = s.match(/"error(?:_code)?"\s*:\s*"([a-z_]+)"/i);
|
||||
if (m && UNRECOVERABLE_OAUTH_ERROR_CODES.has(m[1])) return m[1];
|
||||
// Last resort: the code carried inside a message rather than returned bare
|
||||
// (Cline: "failed to refresh token: invalid_grant").
|
||||
const embedded = s.match(EMBEDDED_OAUTH_ERROR_CODE_RE);
|
||||
if (embedded) return embedded[1].toLowerCase();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ test("extractOAuthErrorCode: bare string code 'invalid_grant'", () => {
|
||||
assert.equal(extractOAuthErrorCode("invalid_grant"), "invalid_grant");
|
||||
});
|
||||
|
||||
test("extractOAuthErrorCode: JSON string body '{\"error\":\"invalid_grant\"}'", () => {
|
||||
test('extractOAuthErrorCode: JSON string body \'{"error":"invalid_grant"}\'', () => {
|
||||
assert.equal(extractOAuthErrorCode('{"error": "invalid_grant"}'), "invalid_grant");
|
||||
});
|
||||
|
||||
@@ -92,11 +92,49 @@ test("extractOAuthErrorCode: transient errors are NOT misclassified (no false po
|
||||
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: "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" },
|
||||
@@ -105,7 +143,8 @@ const SENTINEL_SHAPES: Array<{ name: string; body: string; ct?: string }> = [
|
||||
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 () =>
|
||||
rawResponse(shape.body, 400, shape.ct ?? "application/json")) as unknown as typeof fetch,
|
||||
async () => {
|
||||
const result = await refreshClaudeOAuthToken("dead-refresh-token");
|
||||
assert.ok(
|
||||
@@ -123,14 +162,19 @@ test("refreshClaudeOAuthToken: transient 500 server_error stays null (NOT unreco
|
||||
(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");
|
||||
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 () =>
|
||||
rawResponse("<html>502 Bad Gateway</html>", 502, "text/html")) as unknown as typeof fetch,
|
||||
async () => {
|
||||
const result = await refreshClaudeOAuthToken("token");
|
||||
assert.equal(result, null);
|
||||
@@ -178,3 +222,23 @@ for (const r of FRAGILE_REFRESHERS) {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 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)}`
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user