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
5 changes: 4 additions & 1 deletion src/lib/asana-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,10 @@ export async function refreshTokenIfNeeded(): Promise<boolean> {
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),
})

Expand Down
6 changes: 5 additions & 1 deletion src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
43 changes: 43 additions & 0 deletions test/lib/asana-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down