From 88676b30377f45b7dc5edb3c60cdd2920f950656 Mon Sep 17 00:00:00 2001 From: Paulo Schuller Date: Sun, 16 Aug 2026 12:32:25 -0300 Subject: [PATCH] fix(oauth): refresh Cline tokens with extension JSON contract --- open-sse/services/tokenRefresh.js | 3 ++ open-sse/services/tokenRefresh/providers.js | 47 +++++++++++++++++++++ tests/unit/token-refresh-generic.test.js | 37 ++++++++++++---- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 3160f4a7..dbf11ac2 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -4,6 +4,7 @@ import { refreshXaiToken, refreshAccessToken, refreshKimiToken, + refreshClineToken, refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken, @@ -23,6 +24,7 @@ import { export { refreshAccessToken, refreshKimiToken, + refreshClineToken, refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken, @@ -145,6 +147,7 @@ const REFRESH_HANDLERS = { "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), + cline: (c, log) => refreshClineToken(c.refreshToken, log), zed: () => refreshZedToken(), windsurf: (c, log) => refreshWindsurfToken(c, log), // Kimi Code OAuth (merged into id `kimi`); legacy id still routes here diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index 40f27f51..ca313923 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -147,6 +147,53 @@ export async function refreshKimiToken(refreshToken, credentials, log) { return refreshAccessToken("kimi", refreshToken, credentials, log); } +export async function refreshClineToken(refreshToken, log) { + if (!refreshToken) return null; + + return dedupRefresh("cline", refreshToken, async () => { + try { + const response = await fetch(PROVIDERS.cline?.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + refreshToken, + grantType: "refresh_token", + clientType: "extension", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", { + status: response.status, + error: errorText, + }); + return null; + } + + const body = await response.json(); + const tokens = body?.data || body; + if (!tokens?.accessToken) return null; + + const expiresIn = tokens.expiresAt + ? Math.max(1, Math.floor((new Date(tokens.expiresAt).getTime() - Date.now()) / 1000)) + : (tokens.expiresIn || tokens.expires_in || 3600); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || refreshToken, + expiresIn, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Error refreshing Cline token: ${error.message}`); + return null; + } + }, log); +} + // Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...). export async function refreshClaudeOAuthToken(refreshToken, log) { return refreshAccessToken("claude", refreshToken, {}, log); diff --git a/tests/unit/token-refresh-generic.test.js b/tests/unit/token-refresh-generic.test.js index e14c9038..51cd1ae4 100644 --- a/tests/unit/token-refresh-generic.test.js +++ b/tests/unit/token-refresh-generic.test.js @@ -102,19 +102,38 @@ describe("refreshAccessToken — config-driven profiles", () => { expect(fm).toHaveBeenCalledTimes(1); }); }); - -describe("refreshAccessToken — legacy generic path (no profile)", () => { +describe("Cline refresh", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); afterEach(() => { global.fetch = originalFetch; }); - it("still works for an unprofiled provider via config.refreshUrl/clientId/clientSecret", async () => { - const fm = mockFetchOnce({ access_token: "gen-acc", expires_in: 3600 }); - const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + it("uses the extension JSON refresh contract", async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + const fm = mockFetchOnce({ + data: { + accessToken: "cline-acc", + refreshToken: "cline-rot", + expiresAt, + }, + }); + const { refreshTokenByProvider } = await import( + "open-sse/services/tokenRefresh.js" + ); - await refreshAccessToken("cline", "gen-old", {}, console); + const out = await refreshTokenByProvider( + "cline", + { refreshToken: "cline-old" }, + console + ); - const body = new URLSearchParams(fm.mock.calls[0][1].body); - expect(body.get("grant_type")).toBe("refresh_token"); - expect(body.get("client_id")).toBeTruthy(); + const [, init] = fm.mock.calls[0]; + expect(init.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body)).toEqual({ + refreshToken: "cline-old", + grantType: "refresh_token", + clientType: "extension", + }); + expect(out.accessToken).toBe("cline-acc"); + expect(out.refreshToken).toBe("cline-rot"); + expect(out.expiresIn).toBeGreaterThan(0); }); });