Skip to content

Commit b8cc6e9

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): revoke tokens on disconnect
1 parent ed74a2f commit b8cc6e9

4 files changed

Lines changed: 306 additions & 1 deletion

File tree

apps/sim/app/api/auth/oauth/disconnect/route.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ import {
1212
} from '@sim/testing'
1313
import { beforeEach, describe, expect, it, vi } from 'vitest'
1414

15+
const { mockRevokeQuickBooksToken } = vi.hoisted(() => ({
16+
mockRevokeQuickBooksToken: vi.fn(),
17+
}))
18+
1519
vi.mock('@sim/audit', () => auditMock)
20+
vi.mock('@/lib/oauth/quickbooks', () => ({
21+
revokeQuickBooksToken: mockRevokeQuickBooksToken,
22+
}))
1623

1724
import { POST } from '@/app/api/auth/oauth/disconnect/route'
1825

@@ -21,6 +28,7 @@ describe('OAuth Disconnect API Route', () => {
2128
vi.clearAllMocks()
2229
resetDbChainMock()
2330
dbChainMockFns.where.mockResolvedValue([])
31+
mockRevokeQuickBooksToken.mockResolvedValue(undefined)
2432
})
2533

2634
it('should disconnect provider successfully', async () => {
@@ -56,6 +64,148 @@ describe('OAuth Disconnect API Route', () => {
5664
expect(data.success).toBe(true)
5765
})
5866

67+
it('revokes the QuickBooks refresh token before deleting the local account', async () => {
68+
authMockFns.mockGetSession.mockResolvedValueOnce({
69+
user: { id: 'user-123' },
70+
})
71+
dbChainMockFns.where
72+
.mockResolvedValueOnce([
73+
{
74+
id: 'account-1',
75+
providerId: 'quickbooks',
76+
accessToken: 'access-token',
77+
refreshToken: 'refresh-token',
78+
},
79+
])
80+
.mockResolvedValueOnce([])
81+
82+
const response = await POST(
83+
createMockRequest('POST', {
84+
provider: 'quickbooks',
85+
providerId: 'quickbooks',
86+
accountId: 'account-1',
87+
})
88+
)
89+
90+
expect(response.status).toBe(200)
91+
expect(mockRevokeQuickBooksToken).toHaveBeenCalledWith('refresh-token')
92+
expect(dbChainMockFns.delete).toHaveBeenCalled()
93+
expect(mockRevokeQuickBooksToken.mock.invocationCallOrder[0]).toBeLessThan(
94+
dbChainMockFns.delete.mock.invocationCallOrder[0]
95+
)
96+
})
97+
98+
it('falls back to the QuickBooks access token when no refresh token is stored', async () => {
99+
authMockFns.mockGetSession.mockResolvedValueOnce({
100+
user: { id: 'user-123' },
101+
})
102+
dbChainMockFns.where
103+
.mockResolvedValueOnce([
104+
{
105+
id: 'account-1',
106+
providerId: 'quickbooks',
107+
accessToken: 'access-token',
108+
refreshToken: null,
109+
},
110+
])
111+
.mockResolvedValueOnce([])
112+
113+
const response = await POST(
114+
createMockRequest('POST', {
115+
provider: 'quickbooks',
116+
providerId: 'quickbooks',
117+
accountId: 'account-1',
118+
})
119+
)
120+
121+
expect(response.status).toBe(200)
122+
expect(mockRevokeQuickBooksToken).toHaveBeenCalledWith('access-token')
123+
})
124+
125+
it('keeps QuickBooks credentials locally when Intuit revocation fails', async () => {
126+
authMockFns.mockGetSession.mockResolvedValueOnce({
127+
user: { id: 'user-123' },
128+
})
129+
dbChainMockFns.where.mockResolvedValueOnce([
130+
{
131+
id: 'account-1',
132+
providerId: 'quickbooks',
133+
accessToken: 'access-token',
134+
refreshToken: 'refresh-token',
135+
},
136+
])
137+
mockRevokeQuickBooksToken.mockRejectedValueOnce(new Error('Intuit unavailable'))
138+
139+
const response = await POST(
140+
createMockRequest('POST', {
141+
provider: 'quickbooks',
142+
providerId: 'quickbooks',
143+
accountId: 'account-1',
144+
})
145+
)
146+
const data = await response.json()
147+
148+
expect(response.status).toBe(502)
149+
expect(data.error).toBe('Unable to revoke QuickBooks access. Please try again.')
150+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
151+
})
152+
153+
it('removes a tokenless QuickBooks account without calling Intuit', async () => {
154+
authMockFns.mockGetSession.mockResolvedValueOnce({
155+
user: { id: 'user-123' },
156+
})
157+
dbChainMockFns.where
158+
.mockResolvedValueOnce([
159+
{
160+
id: 'account-1',
161+
providerId: 'quickbooks',
162+
accessToken: null,
163+
refreshToken: null,
164+
},
165+
])
166+
.mockResolvedValueOnce([])
167+
168+
const response = await POST(
169+
createMockRequest('POST', {
170+
provider: 'quickbooks',
171+
providerId: 'quickbooks',
172+
accountId: 'account-1',
173+
})
174+
)
175+
176+
expect(response.status).toBe(200)
177+
expect(mockRevokeQuickBooksToken).not.toHaveBeenCalled()
178+
expect(dbChainMockFns.delete).toHaveBeenCalled()
179+
})
180+
181+
it('does not revoke tokens for non-QuickBooks providers', async () => {
182+
authMockFns.mockGetSession.mockResolvedValueOnce({
183+
user: { id: 'user-123' },
184+
})
185+
dbChainMockFns.where
186+
.mockResolvedValueOnce([
187+
{
188+
id: 'account-1',
189+
providerId: 'google-email',
190+
accessToken: 'access-token',
191+
refreshToken: 'refresh-token',
192+
},
193+
])
194+
.mockResolvedValueOnce([])
195+
196+
const response = await POST(
197+
createMockRequest('POST', {
198+
provider: 'google',
199+
providerId: 'google-email',
200+
accountId: 'account-1',
201+
})
202+
)
203+
204+
expect(response.status).toBe(200)
205+
expect(mockRevokeQuickBooksToken).not.toHaveBeenCalled()
206+
expect(dbChainMockFns.delete).toHaveBeenCalled()
207+
})
208+
59209
it('should handle unauthenticated user', async () => {
60210
authMockFns.mockGetSession.mockResolvedValueOnce(null)
61211

apps/sim/app/api/auth/oauth/disconnect/route.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { account, credential } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5+
import { getErrorMessage } from '@sim/utils/errors'
56
import { and, eq, inArray, like, or } from 'drizzle-orm'
67
import { type NextRequest, NextResponse } from 'next/server'
78
import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections'
@@ -10,6 +11,7 @@ import { getSession } from '@/lib/auth'
1011
import { generateRequestId } from '@/lib/core/utils/request'
1112
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1213
import { deleteCredential } from '@/lib/credentials/deletion'
14+
import { revokeQuickBooksToken } from '@/lib/oauth/quickbooks'
1315
import { captureServerEvent } from '@/lib/posthog/server'
1416

1517
export const dynamic = 'force-dynamic'
@@ -64,11 +66,44 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6466
or(eq(account.providerId, provider), like(account.providerId, `${provider}-%`))
6567
)
6668

67-
const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter)
69+
const targetAccounts = await db
70+
.select({
71+
id: account.id,
72+
providerId: account.providerId,
73+
accessToken: account.accessToken,
74+
refreshToken: account.refreshToken,
75+
})
76+
.from(account)
77+
.where(accountFilter)
6878

6979
const targetAccountIds = targetAccounts.map((a) => a.id)
7080

7181
if (targetAccountIds.length > 0) {
82+
for (const targetAccount of targetAccounts) {
83+
if (targetAccount.providerId !== 'quickbooks') continue
84+
85+
const token = targetAccount.refreshToken?.trim() || targetAccount.accessToken?.trim()
86+
if (!token) {
87+
logger.warn(`[${requestId}] QuickBooks account has no token to revoke`, {
88+
accountId: targetAccount.id,
89+
})
90+
continue
91+
}
92+
93+
try {
94+
await revokeQuickBooksToken(token)
95+
} catch (error) {
96+
logger.error(`[${requestId}] Failed to revoke QuickBooks access`, {
97+
accountId: targetAccount.id,
98+
error: getErrorMessage(error, 'Unknown revocation error'),
99+
})
100+
return NextResponse.json(
101+
{ error: 'Unable to revoke QuickBooks access. Please try again.' },
102+
{ status: 502 }
103+
)
104+
}
105+
}
106+
72107
const credentialsToDelete = await db
73108
.select({
74109
id: credential.id,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockEnv, mockFetch } = vi.hoisted(() => ({
7+
mockEnv: {
8+
QUICKBOOKS_CLIENT_ID: 'quickbooks-client-id' as string | undefined,
9+
QUICKBOOKS_CLIENT_SECRET: 'quickbooks-client-secret' as string | undefined,
10+
},
11+
mockFetch: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
15+
16+
import { revokeQuickBooksToken } from '@/lib/oauth/quickbooks'
17+
18+
describe('revokeQuickBooksToken', () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks()
21+
mockEnv.QUICKBOOKS_CLIENT_ID = 'quickbooks-client-id'
22+
mockEnv.QUICKBOOKS_CLIENT_SECRET = 'quickbooks-client-secret'
23+
vi.stubGlobal('fetch', mockFetch)
24+
})
25+
26+
afterEach(() => {
27+
vi.unstubAllGlobals()
28+
})
29+
30+
it('sends the token to the Intuit revocation endpoint with client authentication', async () => {
31+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }))
32+
33+
await expect(revokeQuickBooksToken(' refresh-token ')).resolves.toBeUndefined()
34+
35+
expect(mockFetch).toHaveBeenCalledOnce()
36+
const [url, init] = mockFetch.mock.calls[0]
37+
expect(url).toBe('https://developer.api.intuit.com/v2/oauth2/tokens/revoke')
38+
expect(init).toMatchObject({
39+
method: 'POST',
40+
headers: {
41+
Accept: 'application/json',
42+
Authorization: `Basic ${Buffer.from(
43+
'quickbooks-client-id:quickbooks-client-secret'
44+
).toString('base64')}`,
45+
'Content-Type': 'application/json',
46+
},
47+
body: JSON.stringify({ token: 'refresh-token' }),
48+
})
49+
expect(init.signal).toBeInstanceOf(AbortSignal)
50+
})
51+
52+
it('rejects before sending when client credentials are missing', async () => {
53+
mockEnv.QUICKBOOKS_CLIENT_SECRET = undefined
54+
55+
await expect(revokeQuickBooksToken('refresh-token')).rejects.toThrow(
56+
'QuickBooks OAuth client credentials are not configured'
57+
)
58+
expect(mockFetch).not.toHaveBeenCalled()
59+
})
60+
61+
it('sanitizes network and timeout failures', async () => {
62+
mockFetch.mockRejectedValueOnce(new DOMException('request timed out', 'AbortError'))
63+
64+
const result = revokeQuickBooksToken('sensitive-refresh-token')
65+
await expect(result).rejects.toThrow('QuickBooks token revocation request failed')
66+
await expect(result).rejects.not.toThrow('sensitive-refresh-token')
67+
})
68+
69+
it('sanitizes non-success responses', async () => {
70+
mockFetch.mockResolvedValueOnce(
71+
new Response('sensitive-refresh-token quickbooks-client-secret', { status: 400 })
72+
)
73+
74+
const result = revokeQuickBooksToken('sensitive-refresh-token')
75+
await expect(result).rejects.toThrow('QuickBooks token revocation failed with HTTP 400')
76+
await expect(result).rejects.not.toThrow('sensitive-refresh-token')
77+
await expect(result).rejects.not.toThrow('quickbooks-client-secret')
78+
})
79+
})

apps/sim/lib/oauth/quickbooks.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AsyncLocalStorage } from 'node:async_hooks'
22
import { generateId } from '@sim/utils/id'
3+
import { env } from '@/lib/core/config/env'
34
import {
45
readResponseJsonWithLimit,
56
readResponseTextWithLimit,
@@ -13,6 +14,8 @@ import {
1314
} from '@/tools/quickbooks/client'
1415

1516
const QUICKBOOKS_ACCOUNT_PREFIX = 'quickbooks:'
17+
const QUICKBOOKS_REVOCATION_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke'
18+
const QUICKBOOKS_MAX_REVOCATION_ERROR_BYTES = 64 * 1024
1619
const UUID_SUFFIX_PATTERN =
1720
/-([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i
1821
const quickBooksCallbackRealmStorage = new AsyncLocalStorage<string>()
@@ -90,6 +93,44 @@ export function parseQuickBooksAccountId(accountId: string): QuickBooksAccountId
9093
return { realmId, subject }
9194
}
9295

96+
/** Revokes an Intuit OAuth grant using the latest available refresh or access token. */
97+
export async function revokeQuickBooksToken(token: string): Promise<void> {
98+
const normalizedToken = token.trim()
99+
if (!normalizedToken) {
100+
throw new Error('QuickBooks token revocation requires a token')
101+
}
102+
103+
const clientId = env.QUICKBOOKS_CLIENT_ID?.trim()
104+
const clientSecret = env.QUICKBOOKS_CLIENT_SECRET?.trim()
105+
if (!clientId || !clientSecret) {
106+
throw new Error('QuickBooks OAuth client credentials are not configured')
107+
}
108+
109+
let response: Response
110+
try {
111+
response = await fetch(QUICKBOOKS_REVOCATION_URL, {
112+
method: 'POST',
113+
headers: {
114+
Accept: 'application/json',
115+
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
116+
'Content-Type': 'application/json',
117+
},
118+
body: JSON.stringify({ token: normalizedToken }),
119+
signal: AbortSignal.timeout(QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS),
120+
})
121+
} catch {
122+
throw new Error('QuickBooks token revocation request failed')
123+
}
124+
125+
if (!response.ok) {
126+
await readResponseTextWithLimit(response, {
127+
maxBytes: QUICKBOOKS_MAX_REVOCATION_ERROR_BYTES,
128+
label: 'QuickBooks token revocation error response',
129+
}).catch(() => {})
130+
throw new Error(`QuickBooks token revocation failed with HTTP ${response.status}`)
131+
}
132+
}
133+
93134
export async function fetchQuickBooksConnectionProfile(
94135
accessToken: string,
95136
callbackRealmId: string

0 commit comments

Comments
 (0)