Skip to content

Commit 6128a18

Browse files
Bill Leoutsakoscursoragent
authored andcommitted
fix(instagram): proactively refresh long-lived tokens before expiry
Meta only allows refreshing still-valid Instagram tokens, so refresh within 14 days of expiry (after the 24h age gate) instead of waiting until after accessTokenExpiresAt. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f8c9516 commit 6128a18

4 files changed

Lines changed: 147 additions & 8 deletions

File tree

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { withLeaderLock } from '@/lib/concurrency/leader-lock'
88
import { coalesceLocally } from '@/lib/concurrency/singleflight'
99
import { decryptSecret } from '@/lib/core/security/encryption'
1010
import { refreshOAuthToken } from '@/lib/oauth'
11+
import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram'
1112
import {
1213
getMicrosoftRefreshTokenExpiry,
1314
isMicrosoftProvider,
@@ -450,6 +451,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
450451
accessTokenExpiresAt: account.accessTokenExpiresAt,
451452
idToken: account.idToken,
452453
scope: account.scope,
454+
updatedAt: account.updatedAt,
453455
})
454456
.from(account)
455457
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
@@ -463,19 +465,33 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
463465

464466
const credential = connections[0]
465467

466-
// Determine whether we should refresh: missing token OR expired token
468+
// Determine whether we should refresh: missing/expired token, or Instagram
469+
// long-lived token nearing expiry (Meta cannot refresh after expiry).
467470
const now = new Date()
468471
const tokenExpiry = credential.accessTokenExpiresAt
469-
const shouldAttemptRefresh =
472+
const accessTokenNeedsRefresh =
470473
!!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now))
474+
const instagramNeedsProactiveRefresh =
475+
!!credential.refreshToken &&
476+
isInstagramProvider(providerId) &&
477+
shouldProactivelyRefreshInstagramToken({
478+
accessTokenExpiresAt: credential.accessTokenExpiresAt,
479+
updatedAt: credential.updatedAt,
480+
now,
481+
})
471482

472-
if (shouldAttemptRefresh) {
473-
return performCoalescedRefresh({
483+
if (accessTokenNeedsRefresh || instagramNeedsProactiveRefresh) {
484+
const fresh = await performCoalescedRefresh({
474485
accountId: credential.id,
475486
providerId,
476487
refreshToken: credential.refreshToken!,
477488
userId,
478489
})
490+
if (fresh) return fresh
491+
if (!accessTokenNeedsRefresh && credential.accessToken) {
492+
return credential.accessToken
493+
}
494+
return null
479495
}
480496

481497
if (!credential.accessToken) {
@@ -550,7 +566,18 @@ export async function refreshAccessTokenIfNeeded(
550566
refreshTokenExpiresAt &&
551567
refreshTokenExpiresAt <= proactiveRefreshThreshold
552568

553-
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
569+
// Instagram long-lived tokens can only be refreshed while still valid.
570+
const instagramNeedsProactiveRefresh =
571+
!!credential.refreshToken &&
572+
isInstagramProvider(credential.providerId) &&
573+
shouldProactivelyRefreshInstagramToken({
574+
accessTokenExpiresAt,
575+
updatedAt: credential.updatedAt,
576+
now,
577+
})
578+
579+
const shouldRefresh =
580+
accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh
554581

555582
const accessToken = credential.accessToken
556583

@@ -567,8 +594,8 @@ export async function refreshAccessTokenIfNeeded(
567594
})
568595
if (fresh) return fresh
569596

570-
// If refresh was only triggered proactively (Microsoft refresh-token aging),
571-
// the still-valid access token is a fine fallback.
597+
// If refresh was only triggered proactively (Microsoft refresh-token aging /
598+
// Instagram long-lived nearing expiry), the still-valid access token is fine.
572599
if (!accessTokenNeedsRefresh && accessToken) {
573600
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
574601
return accessToken
@@ -616,7 +643,18 @@ export async function refreshTokenIfNeeded(
616643
refreshTokenExpiresAt &&
617644
refreshTokenExpiresAt <= proactiveRefreshThreshold
618645

619-
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
646+
// Instagram long-lived tokens can only be refreshed while still valid.
647+
const instagramNeedsProactiveRefresh =
648+
!!credential.refreshToken &&
649+
isInstagramProvider(credential.providerId) &&
650+
shouldProactivelyRefreshInstagramToken({
651+
accessTokenExpiresAt,
652+
updatedAt: credential.updatedAt,
653+
now,
654+
})
655+
656+
const shouldRefresh =
657+
accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh
620658

621659
// If token appears valid and present, return it directly
622660
if (!shouldRefresh) {

apps/sim/lib/oauth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './instagram'
12
export * from './microsoft'
23
export * from './oauth'
34
export * from './types'
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
INSTAGRAM_MIN_TOKEN_AGE_MS,
4+
INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS,
5+
isInstagramProvider,
6+
shouldProactivelyRefreshInstagramToken,
7+
} from '@/lib/oauth/instagram'
8+
9+
describe('instagram oauth helpers', () => {
10+
it('identifies the instagram provider', () => {
11+
expect(isInstagramProvider('instagram')).toBe(true)
12+
expect(isInstagramProvider('facebook')).toBe(false)
13+
})
14+
15+
it('does not refresh when the token is already expired', () => {
16+
const now = new Date('2026-07-11T12:00:00.000Z')
17+
expect(
18+
shouldProactivelyRefreshInstagramToken({
19+
accessTokenExpiresAt: new Date(now.getTime() - 60_000),
20+
updatedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
21+
now,
22+
})
23+
).toBe(false)
24+
})
25+
26+
it('does not refresh tokens younger than 24 hours', () => {
27+
const now = new Date('2026-07-11T12:00:00.000Z')
28+
expect(
29+
shouldProactivelyRefreshInstagramToken({
30+
accessTokenExpiresAt: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000),
31+
updatedAt: new Date(now.getTime() - INSTAGRAM_MIN_TOKEN_AGE_MS + 60_000),
32+
now,
33+
})
34+
).toBe(false)
35+
})
36+
37+
it('refreshes when expiry is within the proactive window and the token is old enough', () => {
38+
const now = new Date('2026-07-11T12:00:00.000Z')
39+
expect(
40+
shouldProactivelyRefreshInstagramToken({
41+
accessTokenExpiresAt: new Date(
42+
now.getTime() + (INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS - 1) * 24 * 60 * 60 * 1000
43+
),
44+
updatedAt: new Date(now.getTime() - INSTAGRAM_MIN_TOKEN_AGE_MS - 60_000),
45+
now,
46+
})
47+
).toBe(true)
48+
})
49+
50+
it('does not refresh when plenty of lifetime remains', () => {
51+
const now = new Date('2026-07-11T12:00:00.000Z')
52+
expect(
53+
shouldProactivelyRefreshInstagramToken({
54+
accessTokenExpiresAt: new Date(
55+
now.getTime() + (INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS + 5) * 24 * 60 * 60 * 1000
56+
),
57+
updatedAt: new Date(now.getTime() - 40 * 24 * 60 * 60 * 1000),
58+
now,
59+
})
60+
).toBe(false)
61+
})
62+
})

apps/sim/lib/oauth/instagram.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/** Refresh while the long-lived token still has this many days left. */
2+
export const INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS = 14
3+
4+
/**
5+
* Meta rejects refresh until the long-lived token is at least 24 hours old.
6+
* @see https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token/
7+
*/
8+
export const INSTAGRAM_MIN_TOKEN_AGE_MS = 24 * 60 * 60 * 1000
9+
10+
export function isInstagramProvider(providerId: string): boolean {
11+
return providerId === 'instagram'
12+
}
13+
14+
/**
15+
* Whether an Instagram long-lived token should be refreshed before it expires.
16+
* Meta cannot refresh expired tokens, so we must refresh while still valid.
17+
*/
18+
export function shouldProactivelyRefreshInstagramToken(options: {
19+
accessTokenExpiresAt?: Date | null
20+
updatedAt?: Date | null
21+
now?: Date
22+
}): boolean {
23+
const now = options.now ?? new Date()
24+
const expiresAt = options.accessTokenExpiresAt
25+
if (!expiresAt || expiresAt <= now) {
26+
return false
27+
}
28+
29+
const updatedAt = options.updatedAt
30+
if (updatedAt && now.getTime() - updatedAt.getTime() < INSTAGRAM_MIN_TOKEN_AGE_MS) {
31+
return false
32+
}
33+
34+
const proactiveThreshold = new Date(
35+
now.getTime() + INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
36+
)
37+
return expiresAt <= proactiveThreshold
38+
}

0 commit comments

Comments
 (0)