diff --git a/CHANGELOG.md b/CHANGELOG.md index 02bb3e8ac8e..612394ce912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - ACP/Security: escape control and delimiter characters in ACP `resource_link` title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - TTS/Security: make model-driven provider switching opt-in by default (`messages.tts.modelOverrides.allowProvider=false` unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. This ships in the next npm release. Thanks @aether-ai-agent for reporting. +- Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses. - BlueBubbles/Security (optional beta iMessage plugin): require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent. - iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky. - Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow. diff --git a/src/agents/chutes-oauth.e2e.test.ts b/src/agents/chutes-oauth.e2e.test.ts index a4292b3eb28..079dbe361bd 100644 --- a/src/agents/chutes-oauth.e2e.test.ts +++ b/src/agents/chutes-oauth.e2e.test.ts @@ -102,4 +102,39 @@ describe("chutes-oauth", () => { expect(refreshed.refresh).toBe("rt_old"); expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000); }); + + it("refreshes tokens and ignores empty refresh_token values", async () => { + const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = urlToString(input); + if (url !== CHUTES_TOKEN_ENDPOINT) { + return new Response("not found", { status: 404 }); + } + expect(init?.method).toBe("POST"); + return new Response( + JSON.stringify({ + access_token: "at_new", + refresh_token: "", + expires_in: 1800, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const now = 3_000_000; + const refreshed = await refreshChutesTokens({ + credential: { + access: "at_old", + refresh: "rt_old", + expires: now - 10_000, + email: "fred", + clientId: "cid_test", + } as unknown as Parameters[0]["credential"], + fetchFn, + now, + }); + + expect(refreshed.access).toBe("at_new"); + expect(refreshed.refresh).toBe("rt_old"); + expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000); + }); }); diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index 2b3abed84d5..02adf10ce01 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -218,6 +218,7 @@ export async function refreshChutesTokens(params: { return { ...params.credential, access, + // RFC 6749 section 6: new refresh token is optional; if present, replace old. refresh: newRefresh || refreshToken, expires: coerceExpiresAt(expiresIn, now), clientId, diff --git a/src/providers/qwen-portal-oauth.test.ts b/src/providers/qwen-portal-oauth.test.ts index 0abe4eddbc9..78b25b583bf 100644 --- a/src/providers/qwen-portal-oauth.test.ts +++ b/src/providers/qwen-portal-oauth.test.ts @@ -58,6 +58,48 @@ describe("refreshQwenPortalCredentials", () => { expect(result.refresh).toBe("old-refresh"); }); + it("keeps refresh token when response sends an empty refresh token", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "", + expires_in: 1800, + }), + }); + vi.stubGlobal("fetch", fetchSpy); + + const result = await refreshQwenPortalCredentials({ + access: "old-access", + refresh: "old-refresh", + expires: Date.now() - 1000, + }); + + expect(result.refresh).toBe("old-refresh"); + }); + + it("errors when refresh response has invalid expires_in", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 0, + }), + }); + vi.stubGlobal("fetch", fetchSpy); + + await expect( + refreshQwenPortalCredentials({ + access: "old-access", + refresh: "old-refresh", + expires: Date.now() - 1000, + }), + ).rejects.toThrow("Qwen OAuth refresh response missing or invalid expires_in"); + }); + it("errors when refresh token is invalid", async () => { const fetchSpy = vi.fn().mockResolvedValue({ ok: false, diff --git a/src/providers/qwen-portal-oauth.ts b/src/providers/qwen-portal-oauth.ts index bbed888c9f9..159942ef2a9 100644 --- a/src/providers/qwen-portal-oauth.ts +++ b/src/providers/qwen-portal-oauth.ts @@ -8,7 +8,8 @@ const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"; export async function refreshQwenPortalCredentials( credentials: OAuthCredentials, ): Promise { - if (!credentials.refresh?.trim()) { + const refreshToken = credentials.refresh?.trim(); + if (!refreshToken) { throw new Error("Qwen OAuth refresh token missing; re-authenticate."); } @@ -20,7 +21,7 @@ export async function refreshQwenPortalCredentials( }, body: new URLSearchParams({ grant_type: "refresh_token", - refresh_token: credentials.refresh, + refresh_token: refreshToken, client_id: QWEN_OAUTH_CLIENT_ID, }), }); @@ -40,15 +41,22 @@ export async function refreshQwenPortalCredentials( refresh_token?: string; expires_in?: number; }; + const accessToken = payload.access_token?.trim(); + const newRefreshToken = payload.refresh_token?.trim(); + const expiresIn = payload.expires_in; - if (!payload.access_token || !payload.expires_in) { + if (!accessToken) { throw new Error("Qwen OAuth refresh response missing access token."); } + if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error("Qwen OAuth refresh response missing or invalid expires_in."); + } return { ...credentials, - access: payload.access_token, - refresh: payload.refresh_token || credentials.refresh, - expires: Date.now() + payload.expires_in * 1000, + access: accessToken, + // RFC 6749 section 6: new refresh token is optional; if present, replace old. + refresh: newRefreshToken || refreshToken, + expires: Date.now() + expiresIn * 1000, }; }