diff --git a/src/lib/asana-client.ts b/src/lib/asana-client.ts index 6b86b5f..12bd938 100644 --- a/src/lib/asana-client.ts +++ b/src/lib/asana-client.ts @@ -428,7 +428,10 @@ export async function refreshTokenIfNeeded(): Promise { saveConfig({ ...config, accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, + // Asana does not rotate refresh tokens: the refresh_token grant response + // omits the field. Keep the stored one or auto-refresh breaks after the + // first renewal. + refreshToken: tokenResponse.refresh_token ?? config.refreshToken, expiresAt: Date.now() + (tokenResponse.expires_in * 1000), }) diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index d0b14fc..00bee77 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -62,7 +62,11 @@ export function buildAuthorizeUrl(params: { export interface OAuthTokenResponse { access_token: string - refresh_token: string + /** + * Present on the authorization_code grant. Asana does not rotate refresh + * tokens, so the refresh_token grant response omits this field. + */ + refresh_token?: string expires_in: number token_type: string } diff --git a/test/lib/asana-client.test.ts b/test/lib/asana-client.test.ts index c1413c7..d942016 100644 --- a/test/lib/asana-client.test.ts +++ b/test/lib/asana-client.test.ts @@ -268,6 +268,49 @@ describe('asana-client module', () => { global.fetch = originalFetch }) + test('keeps stored refresh token when refresh response omits refresh_token', async () => { + // Asana's refresh_token grant response does not rotate the refresh token, + // so the response has no refresh_token field. Persisting `undefined` + // would drop it from config.json and permanently break auto-refresh + // after the first renewal (login silently "expires" ~1h later). + const mockResponse = { + access_token: 'new-access-token', + expires_in: 3600, + token_type: 'bearer', + } + + const originalFetch = global.fetch + try { + global.fetch = mock(() => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + }) as any + + const config: AsanaConfig = { + accessToken: 'old-access-token', + authType: 'oauth', + refreshToken: 'old-refresh-token', + expiresAt: Date.now() - 1000, // Expired + } + + mkdirSync(TEST_CONFIG_DIR, { recursive: true }) + writeFileSync(TEST_CONFIG_FILE, JSON.stringify(config)) + + const result = await refreshTokenIfNeeded() + + expect(result).toBe(true) + + const updatedConfig = JSON.parse(readFileSync(TEST_CONFIG_FILE, 'utf-8')) + expect(updatedConfig.accessToken).toBe('new-access-token') + expect(updatedConfig.refreshToken).toBe('old-refresh-token') + } + finally { + global.fetch = originalFetch + } + }) + test('refreshes token when it will expire soon', async () => { const mockResponse = { access_token: 'new-access-token',