From 1eef3debb685a4b8c4f40e9f742d22b45ad4d95a Mon Sep 17 00:00:00 2001 From: Marcus Olsson <8396880+marcusolsson@users.noreply.github.com> Date: Thu, 21 May 2026 11:49:12 +0200 Subject: [PATCH] fix: validate refresh token exchange response at runtime --- src/plugin/token.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++ src/plugin/token.ts | 16 +++++---- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/plugin/token.test.ts b/src/plugin/token.test.ts index badfeef..aaeb7ba 100644 --- a/src/plugin/token.test.ts +++ b/src/plugin/token.test.ts @@ -405,4 +405,74 @@ describe('refreshAccessTokenDirect', () => { path: { id: 'berget' }, }); }); + + // Issue #12 regression: unchecked type assertion after token refresh + it('returns failure when refresh response is missing token', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + json: async () => ({ expires_in: 3600 }), + ok: true, + } as Response), + ); + + const auth: OAuthAuthDetails = { + access: 'old', + expires: Date.now() - 1000, + refresh: 'refresh-token-malformed', + type: 'oauth', + }; + + const result = await refreshAccessTokenDirect(auth); + + expect(result.success).toBe(false); + if (result.success) throw new Error('result should be failure'); + expect(result.reason).toBe('Invalid token response from refresh endpoint'); + }); + + it('returns failure when refresh response expires_in is not a number', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + json: async () => ({ expires_in: 'not-a-number', token: 'new-token' }), + ok: true, + } as Response), + ); + + const auth: OAuthAuthDetails = { + access: 'old', + expires: Date.now() - 1000, + refresh: 'refresh-token-wrong-type', + type: 'oauth', + }; + + const result = await refreshAccessTokenDirect(auth); + + expect(result.success).toBe(false); + if (result.success) throw new Error('result should be failure'); + expect(result.reason).toBe('Invalid token response from refresh endpoint'); + }); + + it('returns failure for a proxy error wrapped in 200 OK on refresh', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + json: async () => ({ error: 'rate_limited' }), + ok: true, + } as Response), + ); + + const auth: OAuthAuthDetails = { + access: 'old', + expires: Date.now() - 1000, + refresh: 'refresh-token-proxy-error', + type: 'oauth', + }; + + const result = await refreshAccessTokenDirect(auth); + + expect(result.success).toBe(false); + if (result.success) throw new Error('result should be failure'); + expect(result.reason).toBe('Invalid token response from refresh endpoint'); + }); }); diff --git a/src/plugin/token.ts b/src/plugin/token.ts index 3e183c6..1353a79 100644 --- a/src/plugin/token.ts +++ b/src/plugin/token.ts @@ -105,11 +105,15 @@ async function refreshAccessTokenInternal( return { reason: `Token refresh failed: HTTP ${response.status}`, success: false }; } - const data = (await response.json()) as { - expires_in: number; - refresh_token?: string; - token: string; - }; + const data = (await response.json()) as Record; + + if (typeof data.token !== 'string' || typeof data.expires_in !== 'number') { + logDebug('Refresh endpoint returned malformed body'); + return { + success: false, + reason: 'Invalid token response from refresh endpoint', + }; + } logDebug(`Token refreshed, expires_in=${data.expires_in}s`); @@ -118,7 +122,7 @@ async function refreshAccessTokenInternal( ...auth, access: data.token, expires: Date.now() + data.expires_in * 1000, - refresh: data.refresh_token || refreshToken, // Use new refresh token if rotated + refresh: typeof data.refresh_token === 'string' ? data.refresh_token : refreshToken, // Use new refresh token if rotated }; // Persist updated tokens to OpenCode so they survive restarts