Skip to content

Commit 25e5420

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): harden OAuth company binding
1 parent 00fc671 commit 25e5420

13 files changed

Lines changed: 496 additions & 86 deletions

File tree

apps/docs/content/docs/en/integrations/quickbooks.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Get information about the connected QuickBooks Online company
3232
| Parameter | Type | Description |
3333
| --------- | ---- | ----------- |
3434
| `company` | json | Verified QuickBooks CompanyInfo object, including Id, CompanyName, LegalName, addresses, contact details, NameValue settings, and MetaData when populated |
35-
|`Id` | string | Connected QuickBooks company ID |
35+
|`Id` | string | QuickBooks CompanyInfo entity ID \(commonly "1"\); this is not the OAuth realmId |
3636
|`SyncToken` | string | CompanyInfo sync token |
3737
|`CompanyName` | string | Company display name |
3838
|`LegalName` | string | Company legal name |

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('@/lib/auth/anonymous', () => ({
3030
createAnonymousSession: handlerMocks.createAnonymousSession,
3131
}))
3232

33+
import { getQuickBooksCallbackRealm } from '@/lib/oauth/quickbooks'
3334
import { GET, POST } from '@/app/api/auth/[...all]/route'
3435

3536
afterAll(resetEnvFlagsMock)
@@ -132,3 +133,67 @@ describe('auth catch-all route organization mutations', () => {
132133
expect(json).toEqual({ data: { ok: true } })
133134
})
134135
})
136+
137+
describe('auth catch-all route QuickBooks callback', () => {
138+
beforeEach(() => {
139+
vi.clearAllMocks()
140+
setEnvFlags({ isAuthDisabled: false })
141+
})
142+
143+
it('binds the callback realm only while Better Auth processes the OAuth response', async () => {
144+
const { NextResponse } = await import('next/server')
145+
handlerMocks.betterAuthGET.mockImplementationOnce(async () => {
146+
await Promise.resolve()
147+
expect(getQuickBooksCallbackRealm()).toBe('123456789')
148+
return new NextResponse(null, { status: 302 })
149+
})
150+
151+
const req = createMockRequest(
152+
'GET',
153+
undefined,
154+
{},
155+
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test&realmId=123456789'
156+
)
157+
158+
const res = await GET(req as any)
159+
160+
expect(res.status).toBe(302)
161+
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
162+
expect(() => getQuickBooksCallbackRealm()).toThrow(/did not include a company identity/)
163+
})
164+
165+
it('delegates a denied callback without requiring a realm', async () => {
166+
const { NextResponse } = await import('next/server')
167+
handlerMocks.betterAuthGET.mockImplementationOnce(async () => {
168+
expect(() => getQuickBooksCallbackRealm()).toThrow(/did not include a company identity/)
169+
return new NextResponse(null, { status: 302 })
170+
})
171+
172+
const req = createMockRequest(
173+
'GET',
174+
undefined,
175+
{},
176+
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?error=access_denied&state=test'
177+
)
178+
179+
const res = await GET(req as any)
180+
181+
expect(res.status).toBe(302)
182+
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
183+
})
184+
185+
it.each([
186+
['missing', 'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test'],
187+
[
188+
'invalid',
189+
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test&realmId=not-a-company',
190+
],
191+
])('rejects a %s callback realm before Better Auth exchanges the code', async (_, url) => {
192+
const req = createMockRequest('GET', undefined, {}, url)
193+
194+
const res = await GET(req as any)
195+
196+
expect(res.status).toBe(400)
197+
expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled()
198+
})
199+
})

apps/sim/app/api/auth/[...all]/route.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { auth } from '@/lib/auth'
44
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
55
import { isAuthDisabled } from '@/lib/core/config/env-flags'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { normalizeQuickBooksRealmId, withQuickBooksCallbackRealm } from '@/lib/oauth/quickbooks'
78

89
export const dynamic = 'force-dynamic'
910

@@ -27,6 +28,32 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2728
return NextResponse.json(createAnonymousSession())
2829
}
2930

31+
if (path === 'oauth2/callback/quickbooks') {
32+
const authorizationCode = request.nextUrl.searchParams.get('code')
33+
if (!authorizationCode) {
34+
return betterAuthGET(request)
35+
}
36+
37+
const realmId = request.nextUrl.searchParams.get('realmId')
38+
if (!realmId) {
39+
return NextResponse.json(
40+
{ error: 'QuickBooks callback did not include a company identity.' },
41+
{ status: 400 }
42+
)
43+
}
44+
45+
try {
46+
normalizeQuickBooksRealmId(realmId)
47+
} catch {
48+
return NextResponse.json(
49+
{ error: 'QuickBooks callback included an invalid company identity.' },
50+
{ status: 400 }
51+
)
52+
}
53+
54+
return withQuickBooksCallbackRealm(realmId, () => betterAuthGET(request))
55+
}
56+
3057
return betterAuthGET(request)
3158
})
3259

apps/sim/lib/auth/auth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import {
105105
} from '@/lib/oauth/microsoft'
106106
import {
107107
fetchQuickBooksConnectionProfile,
108+
getQuickBooksCallbackRealm,
108109
QUICKBOOKS_AUTHORIZATION_URL,
109110
QUICKBOOKS_OIDC_CLAIMS,
110111
QUICKBOOKS_TOKEN_URL,
@@ -114,6 +115,7 @@ import { clearDeadFlag } from '@/lib/oauth/terminal-errors'
114115
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
115116
import { joinInstanceOrganization } from '@/lib/organizations/instance-org'
116117
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
118+
import { QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS } from '@/lib/quickbooks/client'
117119
import { disableUserResources } from '@/lib/workflows/lifecycle'
118120
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
119121

@@ -3240,6 +3242,7 @@ export const auth = betterAuth({
32403242
grant_type: 'authorization_code',
32413243
redirect_uri: redirectURI,
32423244
}),
3245+
signal: AbortSignal.timeout(QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS),
32433246
})
32443247
const data = await readResponseJsonWithLimit<Record<string, unknown>>(response, {
32453248
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
@@ -3266,7 +3269,10 @@ export const auth = betterAuth({
32663269
throw new Error('QuickBooks OAuth did not issue an access token')
32673270
}
32683271

3269-
const profile = await fetchQuickBooksConnectionProfile(tokens.accessToken)
3272+
const profile = await fetchQuickBooksConnectionProfile(
3273+
tokens.accessToken,
3274+
getQuickBooksCallbackRealm()
3275+
)
32703276

32713277
const now = new Date()
32723278
return {

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ export const env = createEnv({
417417
PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret
418418
QUICKBOOKS_CLIENT_ID: z.string().optional(), // QuickBooks Online OAuth client ID
419419
QUICKBOOKS_CLIENT_SECRET: z.string().optional(), // QuickBooks Online OAuth client secret
420-
QUICKBOOKS_ENV: z.enum(['sandbox', 'production']).optional(), // QuickBooks Online API environment (defaults to sandbox)
420+
QUICKBOOKS_ENV: z.enum(['sandbox', 'production']).optional(), // QuickBooks Online API environment (must be configured explicitly)
421421
LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID
422422
LINEAR_CLIENT_SECRET: z.string().optional(), // Linear OAuth client secret
423423
CLICKUP_CLIENT_ID: z.string().optional(), // ClickUp OAuth client ID
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const mocks = vi.hoisted(() => ({
4+
limit: vi.fn(),
5+
deleteWhere: vi.fn(),
6+
handleCreate: vi.fn(),
7+
handleReconnect: vi.fn(),
8+
loggerError: vi.fn(),
9+
loggerInfo: vi.fn(),
10+
}))
11+
12+
vi.mock('@sim/db', () => ({
13+
db: {
14+
select: vi.fn(() => ({
15+
from: vi.fn(() => ({
16+
where: vi.fn(() => ({
17+
limit: mocks.limit,
18+
})),
19+
})),
20+
})),
21+
delete: vi.fn(() => ({
22+
where: mocks.deleteWhere,
23+
})),
24+
},
25+
}))
26+
27+
vi.mock('@sim/logger', () => ({
28+
createLogger: () => ({
29+
error: mocks.loggerError,
30+
info: mocks.loggerInfo,
31+
}),
32+
}))
33+
34+
vi.mock('@/lib/credentials/draft-hooks', () => ({
35+
handleCreateCredentialFromDraft: mocks.handleCreate,
36+
handleReconnectCredential: mocks.handleReconnect,
37+
}))
38+
39+
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
40+
41+
const draft = {
42+
id: 'draft-1',
43+
userId: 'user-1',
44+
workspaceId: 'workspace-1',
45+
providerId: 'quickbooks',
46+
displayName: 'QuickBooks',
47+
description: null,
48+
credentialId: null,
49+
expiresAt: new Date(Date.now() + 60_000),
50+
createdAt: new Date(),
51+
}
52+
53+
describe('processCredentialDraft', () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
})
57+
58+
it('does nothing when no matching draft exists', async () => {
59+
mocks.limit.mockResolvedValueOnce([])
60+
61+
await processCredentialDraft({
62+
userId: 'user-1',
63+
providerId: 'quickbooks',
64+
accountId: 'account-1',
65+
})
66+
67+
expect(mocks.limit).toHaveBeenCalledWith(2)
68+
expect(mocks.handleCreate).not.toHaveBeenCalled()
69+
expect(mocks.handleReconnect).not.toHaveBeenCalled()
70+
expect(mocks.deleteWhere).not.toHaveBeenCalled()
71+
})
72+
73+
it('creates a credential when exactly one matching draft exists', async () => {
74+
mocks.limit.mockResolvedValueOnce([draft])
75+
76+
await processCredentialDraft({
77+
userId: 'user-1',
78+
providerId: 'quickbooks',
79+
accountId: 'account-1',
80+
})
81+
82+
expect(mocks.handleCreate).toHaveBeenCalledWith(
83+
expect.objectContaining({
84+
draft,
85+
accountId: 'account-1',
86+
providerId: 'quickbooks',
87+
userId: 'user-1',
88+
})
89+
)
90+
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1)
91+
})
92+
93+
it('fails closed instead of binding an account when multiple workspaces have drafts', async () => {
94+
mocks.limit.mockResolvedValueOnce([
95+
draft,
96+
{ ...draft, id: 'draft-2', workspaceId: 'workspace-2' },
97+
])
98+
99+
await processCredentialDraft({
100+
userId: 'user-1',
101+
providerId: 'quickbooks',
102+
accountId: 'account-1',
103+
})
104+
105+
expect(mocks.handleCreate).not.toHaveBeenCalled()
106+
expect(mocks.handleReconnect).not.toHaveBeenCalled()
107+
expect(mocks.deleteWhere).not.toHaveBeenCalled()
108+
expect(mocks.loggerError).toHaveBeenCalledWith(
109+
'Refusing to process ambiguous credential drafts',
110+
expect.objectContaining({ userId: 'user-1', providerId: 'quickbooks', draftCount: 2 })
111+
)
112+
})
113+
})

apps/sim/lib/credentials/draft-processor.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ interface ProcessCredentialDraftParams {
1818
/**
1919
* Looks up a pending credential draft for the given user/provider and processes it.
2020
* Creates a new credential or reconnects an existing one depending on the draft state.
21+
* If more than one workspace has a matching draft, fail closed: an uncorrelated
22+
* OAuth callback cannot safely determine which workspace initiated the connection.
2123
* Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello).
2224
*/
2325
export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise<void> {
2426
const { userId, providerId, accountId } = params
2527

26-
const [draft] = await db
28+
const drafts = await db
2729
.select()
2830
.from(schema.pendingCredentialDraft)
2931
.where(
@@ -33,9 +35,18 @@ export async function processCredentialDraft(params: ProcessCredentialDraftParam
3335
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
3436
)
3537
)
36-
.limit(1)
38+
.limit(2)
3739

40+
const [draft] = drafts
3841
if (!draft) return
42+
if (drafts.length > 1) {
43+
logger.error('Refusing to process ambiguous credential drafts', {
44+
userId,
45+
providerId,
46+
draftCount: drafts.length,
47+
})
48+
return
49+
}
3950

4051
const now = new Date()
4152

0 commit comments

Comments
 (0)