Skip to content

Commit f0090df

Browse files
committed
fix(quickbooks): finalize OAuth configuration handling
1 parent 9d1d476 commit f0090df

25 files changed

Lines changed: 319 additions & 120 deletions

apps/docs/openapi-v2-resources.json

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8006,7 +8006,8 @@
80068006
"type": "string",
80078007
"minLength": 1,
80088008
"maxLength": 512,
8009-
"description": "Write-only client secret for the caller-managed Intuit OAuth application."
8009+
"description": "Write-only client secret for the caller-managed Intuit OAuth application.",
8010+
"writeOnly": true
80108011
},
80118012
"environment": {
80128013
"type": "string",
@@ -8017,7 +8018,8 @@
80178018
"type": "string",
80188019
"minLength": 1,
80198020
"maxLength": 512,
8020-
"description": "Write-only verifier token for webhook signatures from the caller-managed app."
8021+
"description": "Write-only verifier token for webhook signatures from the caller-managed app.",
8022+
"writeOnly": true
80218023
}
80228024
},
80238025
"required": ["clientId", "clientSecret", "environment", "webhookVerifierToken"],
@@ -8125,10 +8127,10 @@
81258127
"type": "string",
81268128
"minLength": 1,
81278129
"maxLength": 255,
8128-
"description": "Existing OAuth credential to reconnect in place."
8130+
"description": "Existing OAuth credential to reconnect in place. QuickBooks reconnects also require oauthClientConfig with the Intuit client ID, client secret, environment, and webhook verifier token."
81298131
},
81308132
"oauthClientConfig": {
8131-
"description": "Write-only OAuth app configuration when required by the provider.",
8133+
"description": "Write-only Intuit OAuth app configuration. Required when credentialId identifies a QuickBooks credential; omit it for other providers.",
81328134
"type": "object",
81338135
"properties": {
81348136
"clientId": {
@@ -8141,7 +8143,8 @@
81418143
"type": "string",
81428144
"minLength": 1,
81438145
"maxLength": 512,
8144-
"description": "Write-only client secret for the caller-managed Intuit OAuth application."
8146+
"description": "Write-only client secret for the caller-managed Intuit OAuth application.",
8147+
"writeOnly": true
81458148
},
81468149
"environment": {
81478150
"type": "string",
@@ -8152,7 +8155,8 @@
81528155
"type": "string",
81538156
"minLength": 1,
81548157
"maxLength": 512,
8155-
"description": "Write-only verifier token for webhook signatures from the caller-managed app."
8158+
"description": "Write-only verifier token for webhook signatures from the caller-managed app.",
8159+
"writeOnly": true
81568160
}
81578161
},
81588162
"required": ["clientId", "clientSecret", "environment", "webhookVerifierToken"],

apps/sim/app/api/auth/oauth2/authorize/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ describe('OAuth2 authorize route', () => {
303303
providerId: 'quickbooks',
304304
workspaceId: WORKSPACE_ID,
305305
credentialId: null,
306-
encryptedOAuthClientConfig: 'encrypted-config',
306+
oauthConfig: 'encrypted-config',
307307
},
308308
})
309309
const callbackURL = `${BASE_URL}/workspace/${WORKSPACE_ID}/integrations`

apps/sim/app/api/auth/oauth2/authorize/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5959
workspaceId = draft.workspaceId
6060
credentialId = draft.credentialId ?? undefined
6161
connectionDraftId = draft.id
62-
encryptedQuickBooksClientConfig = draft.encryptedOAuthClientConfig
62+
encryptedQuickBooksClientConfig = draft.oauthConfig
6363
fromConnectionDraft = true
6464
} catch (error) {
6565
if (!(error instanceof OrchestrationError)) throw error
@@ -132,7 +132,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
132132
input: { draftId: connectionDraftId },
133133
request,
134134
})
135-
encryptedQuickBooksClientConfig = draft.encryptedOAuthClientConfig
135+
encryptedQuickBooksClientConfig = draft.oauthConfig
136136
}
137137
if (!encryptedQuickBooksClientConfig) {
138138
throw new Error('QuickBooks OAuth client configuration is missing')

apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { NextRequest } from 'next/server'
66
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
77
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
88

9-
const { mockClientConfig, mockEnqueue, mockRelease } = vi.hoisted(() => ({
10-
mockClientConfig: vi.fn(),
9+
const { mockVerifierTokens, mockEnqueue, mockRelease } = vi.hoisted(() => ({
10+
mockVerifierTokens: vi.fn(),
1111
mockEnqueue: vi.fn(),
1212
mockRelease: vi.fn(),
1313
}))
@@ -20,7 +20,7 @@ vi.mock('@/lib/core/admission/gate', () => ({
2020
tryAdmit: vi.fn(() => ({ release: mockRelease })),
2121
}))
2222
vi.mock('@/lib/webhooks/quickbooks-credentials', () => ({
23-
getQuickBooksWebhookClientConfigByAppKey: mockClientConfig,
23+
getQuickBooksWebhookVerifierTokensByAppKey: mockVerifierTokens,
2424
}))
2525
vi.mock('@/lib/core/utils/with-route-handler', () => ({
2626
withRouteHandler:
@@ -58,9 +58,9 @@ function request(body: string, signature?: string): NextRequest {
5858
})
5959
}
6060

61-
function signedRequest(value: unknown): NextRequest {
61+
function signedRequest(value: unknown, verifierToken = 'verifier'): NextRequest {
6262
const body = JSON.stringify(value)
63-
const signature = crypto.createHmac('sha256', 'verifier').update(body).digest('base64')
63+
const signature = crypto.createHmac('sha256', verifierToken).update(body).digest('base64')
6464
return request(body, signature)
6565
}
6666

@@ -71,7 +71,7 @@ function callPost(webhookRequest: NextRequest, appKey = APP_KEY): Promise<Respon
7171
describe('QuickBooks webhook ingress route', () => {
7272
beforeEach(() => {
7373
vi.clearAllMocks()
74-
mockClientConfig.mockResolvedValue({ webhookVerifierToken: 'verifier' })
74+
mockVerifierTokens.mockResolvedValue(['verifier'])
7575
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1')
7676
mockEnqueue.mockResolvedValue('job-1')
7777
})
@@ -85,7 +85,7 @@ describe('QuickBooks webhook ingress route', () => {
8585
signedRequest([validEvent, { ...validEvent, id: 'event-2', intuitaccountid: '789' }])
8686
)
8787
expect(response.status).toBe(200)
88-
expect(mockClientConfig).toHaveBeenCalledWith(APP_KEY)
88+
expect(mockVerifierTokens).toHaveBeenCalledWith(APP_KEY)
8989
expect(mockEnqueue).toHaveBeenCalledWith(
9090
expect.objectContaining({
9191
appKey: APP_KEY,
@@ -96,9 +96,15 @@ describe('QuickBooks webhook ingress route', () => {
9696
expect(mockRelease).toHaveBeenCalledOnce()
9797
})
9898

99+
it('accepts any verifier token configured by a connection for the same Intuit app', async () => {
100+
mockVerifierTokens.mockResolvedValue(['stale-verifier', 'current-verifier'])
101+
102+
expect((await callPost(signedRequest([validEvent], 'current-verifier'))).status).toBe(200)
103+
})
104+
99105
it('fails closed for unknown app keys and missing signatures', async () => {
100106
expect((await callPost(signedRequest([validEvent]), 'invalid')).status).toBe(404)
101-
mockClientConfig.mockResolvedValueOnce(null)
107+
mockVerifierTokens.mockResolvedValueOnce([])
102108
expect((await callPost(signedRequest([validEvent]))).status).toBe(404)
103109
expect((await callPost(request(JSON.stringify([validEvent])))).status).toBe(401)
104110
expect(mockEnqueue).not.toHaveBeenCalled()
@@ -119,7 +125,7 @@ describe('QuickBooks webhook ingress route', () => {
119125
oversizedRequest.headers.set('content-length', String(WEBHOOK_MAX_BODY_BYTES + 1))
120126

121127
expect((await callPost(oversizedRequest)).status).toBe(413)
122-
expect(mockClientConfig).not.toHaveBeenCalled()
128+
expect(mockVerifierTokens).not.toHaveBeenCalled()
123129
expect(mockEnqueue).not.toHaveBeenCalled()
124130
})
125131

apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import {
1414
} from '@/lib/core/utils/stream-limits'
1515
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1616
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
17-
import { verifyQuickBooksSignature } from '@/lib/webhooks/providers/quickbooks'
18-
import { getQuickBooksWebhookClientConfigByAppKey } from '@/lib/webhooks/quickbooks-credentials'
17+
import { verifyQuickBooksSignatureAgainstVerifierTokens } from '@/lib/webhooks/providers/quickbooks'
18+
import { getQuickBooksWebhookVerifierTokensByAppKey } from '@/lib/webhooks/quickbooks-credentials'
1919
import {
2020
enqueueQuickBooksWebhookIngress,
2121
type QuickBooksWebhookIngressPayload,
@@ -62,14 +62,14 @@ export const POST = withRouteHandler(
6262
throw error
6363
}
6464

65-
const clientConfig = await getQuickBooksWebhookClientConfigByAppKey(appKey)
66-
if (!clientConfig) {
65+
const verifierTokens = await getQuickBooksWebhookVerifierTokensByAppKey(appKey)
66+
if (verifierTokens.length === 0) {
6767
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
6868
}
69-
const authError = verifyQuickBooksSignature(
69+
const authError = verifyQuickBooksSignatureAgainstVerifierTokens(
7070
rawBody,
7171
request.headers.get('intuit-signature'),
72-
clientConfig.webhookVerifierToken,
72+
verifierTokens,
7373
requestId
7474
)
7575
if (authError) return authError

apps/sim/lib/api/contracts/credentials.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,8 @@ export const quickBooksOAuthClientConfigSchema = z
315315
.trim()
316316
.min(1, 'QuickBooks client secret is required')
317317
.max(512, 'QuickBooks client secret must be at most 512 characters')
318-
.describe('Write-only client secret for the caller-managed Intuit OAuth application.'),
318+
.describe('Write-only client secret for the caller-managed Intuit OAuth application.')
319+
.meta({ writeOnly: true }),
319320
environment: z
320321
.enum(['sandbox', 'production'], {
321322
error: 'QuickBooks environment must be sandbox or production',
@@ -326,7 +327,8 @@ export const quickBooksOAuthClientConfigSchema = z
326327
.trim()
327328
.min(1, 'QuickBooks webhook verifier token is required')
328329
.max(512, 'QuickBooks webhook verifier token must be at most 512 characters')
329-
.describe('Write-only verifier token for webhook signatures from the caller-managed app.'),
330+
.describe('Write-only verifier token for webhook signatures from the caller-managed app.')
331+
.meta({ writeOnly: true }),
330332
})
331333
.strict()
332334

apps/sim/lib/api/contracts/v2/credentials.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { z } from 'zod'
56
import {
67
V2_OAUTH_CONNECTION_PROVIDER_IDS,
78
v2CreateCredentialConnectionBodySchema,
@@ -16,6 +17,20 @@ const QUICKBOOKS_CONFIG = {
1617
webhookVerifierToken: 'verifier-token',
1718
}
1819

20+
interface CredentialConnectionJsonSchema {
21+
anyOf?: CredentialConnectionJsonSchema[]
22+
properties?: {
23+
credentialId?: { description?: string }
24+
oauthClientConfig?: {
25+
description?: string
26+
properties?: {
27+
clientSecret?: { writeOnly?: boolean }
28+
webhookVerifierToken?: { writeOnly?: boolean }
29+
}
30+
}
31+
}
32+
}
33+
1934
describe('v2CreateCredentialConnectionBodySchema', () => {
2035
it('keeps the documented provider enum in sync with provider discovery', () => {
2136
const discoveredProviderIds = getAllOAuthServices()
@@ -63,4 +78,23 @@ describe('v2CreateCredentialConnectionBodySchema', () => {
6378
}).success
6479
).toBe(true)
6580
})
81+
82+
it('publishes QuickBooks reconnect requirements and secret fields accurately', () => {
83+
const published = z.toJSONSchema(v2CreateCredentialConnectionBodySchema, {
84+
io: 'input',
85+
unrepresentable: 'any',
86+
}) as CredentialConnectionJsonSchema
87+
const newQuickBooksConfig = published.anyOf?.[0]?.anyOf?.[0]?.properties?.oauthClientConfig
88+
const reconnect = published.anyOf?.[1]
89+
const reconnectConfig = reconnect?.properties?.oauthClientConfig
90+
91+
expect(reconnect?.properties?.credentialId?.description).toContain(
92+
'QuickBooks reconnects also require oauthClientConfig'
93+
)
94+
expect(reconnectConfig?.description).toContain('Required when credentialId identifies')
95+
for (const config of [newQuickBooksConfig, reconnectConfig]) {
96+
expect(config?.properties?.clientSecret?.writeOnly).toBe(true)
97+
expect(config?.properties?.webhookVerifierToken?.writeOnly).toBe(true)
98+
}
99+
})
66100
})

apps/sim/lib/api/contracts/v2/credentials.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,10 +341,14 @@ const v2CreateCredentialConnectionByCredentialSchema = z
341341
.trim()
342342
.min(1, 'credentialId cannot be empty')
343343
.max(255, 'credentialId must be at most 255 characters')
344-
.describe('Existing OAuth credential to reconnect in place.'),
344+
.describe(
345+
'Existing OAuth credential to reconnect in place. QuickBooks reconnects also require oauthClientConfig with the Intuit client ID, client secret, environment, and webhook verifier token.'
346+
),
345347
oauthClientConfig: quickBooksOAuthClientConfigSchema
346348
.optional()
347-
.describe('Write-only OAuth app configuration when required by the provider.'),
349+
.describe(
350+
'Write-only Intuit OAuth app configuration. Required when credentialId identifies a QuickBooks credential; omit it for other providers.'
351+
),
348352
})
349353
.strict()
350354

apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe('completeQuickBooksConnection', () => {
7474
credentialId: null,
7575
displayName: 'QuickBooks Sandbox',
7676
description: null,
77-
encryptedOAuthClientConfig: 'encrypted-client-config',
77+
oauthConfig: 'encrypted-client-config',
7878
createdAt: new Date('2026-09-04T18:00:00.000Z'),
7979
expiresAt: new Date('2026-09-04T18:10:00.000Z'),
8080
})
@@ -147,7 +147,7 @@ describe('completeQuickBooksConnection', () => {
147147
accountId:
148148
'quickbooks:v2:bGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE:sandbox:1234567890:dXNlci0x',
149149
providerId: 'quickbooks',
150-
encryptedOAuthClientConfig: 'encrypted-client-config',
150+
oauthConfig: 'encrypted-client-config',
151151
scope: 'com.intuit.quickbooks.accounting openid',
152152
})
153153
)
@@ -166,7 +166,7 @@ describe('completeQuickBooksConnection', () => {
166166
workspaceId: 'workspace-1',
167167
providerId: 'quickbooks',
168168
credentialId: null,
169-
encryptedOAuthClientConfig: null,
169+
oauthConfig: null,
170170
})
171171

172172
await expect(

apps/sim/lib/credentials/application/complete-quickbooks-connection.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@ export const completeQuickBooksConnection = defineAuthorizedWorkspaceUseCase({
6363
throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches')
6464
}
6565

66-
const encryptedOAuthClientConfig = context.draft.encryptedOAuthClientConfig
67-
if (!encryptedOAuthClientConfig) {
66+
const oauthConfig = context.draft.oauthConfig
67+
if (!oauthConfig) {
6868
throw new OrchestrationError('validation', 'QuickBooks OAuth client configuration is missing')
6969
}
70-
const clientConfig = await decryptQuickBooksOAuthClientConfig(encryptedOAuthClientConfig)
70+
const clientConfig = await decryptQuickBooksOAuthClientConfig(oauthConfig)
7171
const tokens = await exchangeQuickBooksAuthorizationCode({
7272
code: input.code,
7373
redirectUri: input.redirectUri,
@@ -102,7 +102,7 @@ export const completeQuickBooksConnection = defineAuthorizedWorkspaceUseCase({
102102
accessTokenExpiresAt,
103103
refreshTokenExpiresAt,
104104
scope: tokens.scope || getCanonicalScopesForProvider('quickbooks').join(' '),
105-
encryptedOAuthClientConfig,
105+
oauthConfig,
106106
updatedAt: now,
107107
}
108108
if (existing) {

0 commit comments

Comments
 (0)