Skip to content

Commit 922b667

Browse files
authored
improvement(oauth): serve still-valid tokens while refreshing ahead of expiry (#9509)
1 parent c2cb498 commit 922b667

6 files changed

Lines changed: 374 additions & 180 deletions

File tree

‎apps/sim/lib/oauth/credential-service.test.ts‎

Lines changed: 111 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { generateKeyPairSync, verify } from 'node:crypto'
22
import { account, credential } from '@sim/db/schema'
3-
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
3+
import {
4+
createDeferred,
5+
dbChainMockFns,
6+
flushMicrotasks,
7+
queueTableRows,
8+
resetDbChainMock,
9+
} from '@sim/testing'
410
import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock'
511
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
612
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -435,14 +441,77 @@ describe('OAuth access-token refresh headroom', () => {
435441

436442
describe.each(readers)('$name', ({ read, throwsOnFailure }) => {
437443
it.each([
438-
{ remainingMs: 300_000, refresh: true },
439-
{ remainingMs: 300_001, refresh: false },
440-
{ remainingMs: null, refresh: false },
441-
])('handles $remainingMs milliseconds remaining', async ({ remainingMs, refresh }) => {
442-
await expect(read(createOAuthAccount(remainingMs))).resolves.toBe(
443-
refresh ? 'refreshed-access-token' : 'original-access-token'
444+
{ providerId: 'google-drive', remainingMs: 120_000, refresh: 'wait' },
445+
{ providerId: 'google-drive', remainingMs: 240_000, refresh: 'ahead' },
446+
{ providerId: 'google-drive', remainingMs: 300_001, refresh: 'none' },
447+
{ providerId: 'google-drive', remainingMs: null, refresh: 'none' },
448+
{ providerId: 'confluence', remainingMs: 300_000, refresh: 'wait' },
449+
{ providerId: 'confluence', remainingMs: 300_001, refresh: 'none' },
450+
])(
451+
'handles $providerId with $remainingMs milliseconds remaining',
452+
async ({ providerId, remainingMs, refresh }) => {
453+
await expect(read({ ...createOAuthAccount(remainingMs), providerId })).resolves.toBe(
454+
refresh === 'wait' ? 'refreshed-access-token' : 'original-access-token'
455+
)
456+
if (refresh === 'ahead') {
457+
await vi.waitFor(() => expect(dbChainMockFns.set).toHaveBeenCalled())
458+
}
459+
expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(refresh === 'none' ? 0 : 1)
460+
}
461+
)
462+
463+
it('uses a still-valid Google token while its refresh is still with the provider', async () => {
464+
const provider = createDeferred<unknown>()
465+
mocks.refreshOAuthToken.mockReturnValue(provider.promise)
466+
await expect(read(createOAuthAccount(240_000))).resolves.toBe('original-access-token')
467+
await vi.waitFor(() => expect(mocks.refreshOAuthToken).toHaveBeenCalledOnce())
468+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
469+
provider.resolve({
470+
ok: true,
471+
accessToken: 'refreshed-access-token',
472+
refreshToken: 'rotated-refresh-token',
473+
expiresIn: 3600,
474+
})
475+
await vi.waitFor(() =>
476+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
477+
expect.objectContaining({
478+
accessToken: 'refreshed-access-token',
479+
refreshToken: 'rotated-refresh-token',
480+
})
481+
)
444482
)
445-
expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(refresh ? 1 : 0)
483+
const guard = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1))
484+
expect(guard).toContain('original-refresh-token')
485+
})
486+
487+
it.each([
488+
{
489+
label: 'a provider not verified for refresh-ahead',
490+
providerId: 'confluence',
491+
remainingMs: 240_000,
492+
},
493+
{
494+
label: 'a Google token under the refresh floor',
495+
providerId: 'google-drive',
496+
remainingMs: 60_000,
497+
},
498+
])('waits for the provider with $label', async ({ providerId, remainingMs }) => {
499+
const provider = createDeferred<unknown>()
500+
mocks.refreshOAuthToken.mockReturnValue(provider.promise)
501+
let settled = false
502+
const pending = read({ ...createOAuthAccount(remainingMs), providerId }).finally(() => {
503+
settled = true
504+
})
505+
await vi.waitFor(() => expect(mocks.refreshOAuthToken).toHaveBeenCalledOnce())
506+
await flushMicrotasks(20)
507+
expect(settled).toBe(false)
508+
provider.resolve({
509+
ok: true,
510+
accessToken: 'refreshed-access-token',
511+
refreshToken: 'rotated-refresh-token',
512+
expiresIn: 3600,
513+
})
514+
await expect(pending).resolves.toBe('refreshed-access-token')
446515
})
447516

448517
it('still refreshes a missing access token without a known expiry', async () => {
@@ -614,7 +683,40 @@ describe('OAuth access-token refresh headroom', () => {
614683
accessToken: 'original-access-token',
615684
refreshed: false,
616685
})
617-
expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(2)
686+
await vi.waitFor(() => expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(2))
687+
})
688+
689+
it('stops a cancelled caller waiting while a concurrent caller still gets the refreshed token', async () => {
690+
const { coalesceLocally } = await vi.importActual<
691+
typeof import('@/lib/concurrency/singleflight')
692+
>('@/lib/concurrency/singleflight')
693+
mocks.coalesceLocally.mockImplementation(coalesceLocally)
694+
const provider = createDeferred<unknown>()
695+
mocks.refreshOAuthToken.mockReturnValue(provider.promise)
696+
const controller = new AbortController()
697+
const reason = new DOMException('Search deadline', 'TimeoutError')
698+
queueCredentialAccount(createOAuthAccount())
699+
queueCredentialAccount(createOAuthAccount())
700+
const cancelled = resolveCredentialTokenBundle(
701+
RAW_CREDENTIAL_ID,
702+
RAW_USER_ID,
703+
'test',
704+
undefined,
705+
undefined,
706+
{ signal: controller.signal }
707+
)
708+
const concurrent = resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test')
709+
await vi.waitFor(() => expect(mocks.refreshOAuthToken).toHaveBeenCalledOnce())
710+
controller.abort(reason)
711+
await expect(cancelled).rejects.toBe(reason)
712+
provider.resolve({
713+
ok: true,
714+
accessToken: 'refreshed-access-token',
715+
refreshToken: 'rotated-refresh-token',
716+
expiresIn: 3600,
717+
})
718+
await expect(concurrent).resolves.toEqual({ accessToken: 'refreshed-access-token' })
719+
expect(mocks.refreshOAuthToken).toHaveBeenCalledOnce()
618720
})
619721
})
620722

0 commit comments

Comments
 (0)