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
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { db } from '@db';
import { requestValidCredentials } from './ensure-valid-credentials';
import { refreshExpiringTokensSchedule } from './refresh-expiring-tokens-schedule';

jest.mock('@db', () => ({
db: {
integrationConnection: { findMany: jest.fn() },
},
}));

jest.mock('@trigger.dev/sdk', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
schedules: {
task: (config: unknown) => config,
},
}));

jest.mock('./ensure-valid-credentials', () => ({
requestValidCredentials: jest.fn(),
}));

describe('refreshExpiringTokensSchedule', () => {
const nowMs = Date.parse('2026-04-24T00:00:00.000Z');
const lookaheadMs = 24 * 60 * 60 * 1000;

beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(nowMs);
(requestValidCredentials as jest.Mock).mockResolvedValue({ success: true });
});

afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});

it('refreshes only connections whose latest credential version expires soon', async () => {
const connectionWithOldVersionExpiringSoon = {
id: 'conn_old_soon',
providerSlug: 'example',
organizationId: 'org_1',
organization: { id: 'org_1', name: 'Org 1' },
credentialVersions: [
{ expiresAt: new Date('2026-04-26T00:00:00.000Z') },
{ expiresAt: new Date('2026-04-24T12:00:00.000Z') },
],
};

const connectionWithLatestExpiringSoon = {
id: 'conn_latest_soon',
providerSlug: 'example',
organizationId: 'org_2',
organization: { id: 'org_2', name: 'Org 2' },
credentialVersions: [{ expiresAt: new Date('2026-04-24T12:00:00.000Z') }],
};

(db.integrationConnection.findMany as jest.Mock).mockResolvedValue([
connectionWithOldVersionExpiringSoon,
connectionWithLatestExpiringSoon,
]);

const result = await refreshExpiringTokensSchedule.run({
timestamp: new Date(nowMs).toISOString(),
lastTimestamp: null,
} as any);

expect(result.refreshed).toBe(1);
expect(requestValidCredentials).toHaveBeenCalledTimes(1);
expect(requestValidCredentials).toHaveBeenCalledWith({
apiUrl: expect.any(String),
connectionId: 'conn_latest_soon',
organizationId: 'org_2',
forceRefresh: true,
});
});

it('skips connections whose latest version is not expiring soon', async () => {
const connectionLatestValid = {
id: 'conn_latest_valid',
providerSlug: 'example',
organizationId: 'org_3',
organization: { id: 'org_3', name: 'Org 3' },
credentialVersions: [{ expiresAt: new Date('2026-04-25T12:00:00.000Z') }],
};

(db.integrationConnection.findMany as jest.Mock).mockResolvedValue([
connectionLatestValid,
]);

const result = await refreshExpiringTokensSchedule.run({
timestamp: new Date(nowMs).toISOString(),
lastTimestamp: null,
} as any);

expect(result.refreshed).toBe(0);
expect(requestValidCredentials).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { db } from '@db';
import { logger, schedules } from '@trigger.dev/sdk';
import { requestValidCredentials } from './ensure-valid-credentials';

// Refresh tokens expiring within the next 24 hours
const REFRESH_LOOKAHEAD_HOURS = 24;

/**
* Daily scheduled task that proactively refreshes OAuth tokens before they
* expire. Prevents the "OAuth token expired. Please reconnect" error caused by
* tokens expiring between scheduled check runs.
*
* Runs 1 hour before the daily integration checks (05:00 UTC vs 06:00 UTC) so
* tokens are always fresh when checks execute.
*/
export const refreshExpiringTokensSchedule = schedules.task({
id: 'refresh-expiring-tokens-schedule',
cron: '0 5 * * *', // Daily at 05:00 UTC — 1 hour before integration checks
maxDuration: 60 * 30, // 30 minutes
run: async (payload) => {
logger.info('Starting proactive OAuth token refresh', {
scheduledAt: payload.timestamp,
lastRun: payload.lastTimestamp,
});

const apiUrl = process.env.API_URL;
if (!apiUrl) {
logger.error('API_URL environment variable is not set — cannot refresh tokens');
return { refreshed: 0, failed: 0, skipped: 0 };
}

const now = new Date();
const lookaheadMs = REFRESH_LOOKAHEAD_HOURS * 60 * 60 * 1000;
const expiryThreshold = new Date(now.getTime() + lookaheadMs);

// Find all active connections and check the expiry of the latest credential
// version. Older credential versions may exist, so a `some` predicate would
// incorrectly select connections where an older version is expiring while
// the latest version is still valid.
const activeConnections = await db.integrationConnection.findMany({
where: { status: 'active' },
include: {
organization: { select: { id: true, name: true } },
credentialVersions: {
orderBy: { version: 'desc' },
take: 1,
select: { expiresAt: true },
},
},
});

const expiringConnections = activeConnections.filter((connection) => {
const expiresAt = connection.credentialVersions[0]?.expiresAt;
return (
expiresAt !== undefined &&
expiresAt !== null &&
expiresAt <= expiryThreshold &&
expiresAt > now
);
});

logger.info(`Found ${expiringConnections.length} connections with tokens expiring within ${REFRESH_LOOKAHEAD_HOURS}h`);

let refreshed = 0;
let failed = 0;
let skipped = 0;

for (const connection of expiringConnections) {
const expiresAt = connection.credentialVersions[0]?.expiresAt;
const minutesUntilExpiry = expiresAt
? Math.round((expiresAt.getTime() - Date.now()) / 60_000)
: null;

logger.info(`Refreshing token for connection ${connection.id}`, {
organizationId: connection.organizationId,
organizationName: connection.organization?.name,
minutesUntilExpiry,
});

const result = await requestValidCredentials({
apiUrl,
connectionId: connection.id,
organizationId: connection.organizationId,
forceRefresh: true,
});

if (result.success) {
refreshed++;
logger.info(`Successfully refreshed token for connection ${connection.id}`);
} else {
failed++;
logger.warn(`Failed to refresh token for connection ${connection.id}`, {
error: result.error,
status: result.status,
});
}
}

logger.info('Proactive OAuth token refresh complete', {
total: expiringConnections.length,
refreshed,
failed,
skipped,
});

return { refreshed, failed, skipped, total: expiringConnections.length };
},
});
Loading