Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/plugin/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
16 changes: 10 additions & 6 deletions src/plugin/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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`);

Expand All @@ -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
Expand Down
Loading