Skip to content
Open
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
19 changes: 19 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2710,6 +2710,25 @@ export function BrexIcon(props: SVGProps<SVGSVGElement>) {
)
}

/**
* Official QuickBooks circular mark, cropped from the user-supplied
* Intuit_QuickBooks_logo.svg wordmark.
*/
export function QuickBooksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 61.54 61.54' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill='#2CA01C'
d='M30.77 61.54c16.99 0 30.77-13.78 30.77-30.77S47.76 0 30.77 0 0 13.78 0 30.77s13.77 30.77 30.77 30.77Z'
/>
<path
fill='#FFF'
d='M20.51 18.8c-6.61 0-11.97 5.36-11.97 11.97s5.35 11.96 11.97 11.96h1.71v-4.44h-1.71c-4.15 0-7.52-3.37-7.52-7.52 0-4.15 3.37-7.52 7.52-7.52h4.11V46.5c0 2.45 1.99 4.44 4.44 4.44V18.8h-8.55Zm20.52 23.93c6.61 0 11.97-5.36 11.97-11.96S47.65 18.81 41.03 18.81h-1.71v4.44h1.71c4.15 0 7.52 3.37 7.52 7.52s-3.37 7.52-7.52 7.52h-4.11V15.04c0-2.45-1.99-4.44-4.44-4.44v32.13h8.55Z'
/>
</svg>
)
}

export function BrightDataIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ import {
PulseIcon,
QdrantIcon,
QuartrIcon,
QuickBooksIcon,
QuiverIcon,
RabbitmqIcon,
RailwayIcon,
Expand Down Expand Up @@ -493,6 +494,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quartr: QuartrIcon,
quickbooks: QuickBooksIcon,
quiver: QuiverIcon,
rabbitmq: RabbitmqIcon,
railway: RailwayIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@
"pulse",
"qdrant",
"quartr",
"quickbooks",
"quiver",
"rabbitmq",
"railway",
Expand Down
2,959 changes: 2,959 additions & 0 deletions apps/docs/content/docs/en/integrations/quickbooks.mdx

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# TIKTOK_CLIENT_ID=
# TIKTOK_CLIENT_SECRET=

# QuickBooks Online OAuth (Optional - credentials from the Intuit Developer Portal)
# QUICKBOOKS_CLIENT_ID=
# QUICKBOOKS_CLIENT_SECRET=
# QUICKBOOKS_ENV=sandbox # Required when QuickBooks is configured: sandbox or production

# Azure Blob Storage
# AZURE_ACCOUNT_NAME= # Azure storage account name
# AZURE_ACCOUNT_KEY= # Azure storage account key
Expand Down
63 changes: 63 additions & 0 deletions apps/sim/app/api/auth/[...all]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ vi.mock('@/app/api/credential-groups/oauth-callback', () => ({
handleCredentialGroupOAuthCallback: handlerMocks.credentialGroupCallback,
}))

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

afterAll(resetEnvFlagsMock)
Expand Down Expand Up @@ -132,6 +133,68 @@ describe('auth catch-all route managed OAuth callbacks', () => {
})
})

describe('auth catch-all route QuickBooks callback', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnvFlags({ isAuthDisabled: false })
})

it('binds the callback realm only while Better Auth processes the OAuth response', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthGET.mockImplementationOnce(async () => {
await Promise.resolve()
expect(getQuickBooksCallbackRealm()).toBe('123456789')
return new NextResponse(null, { status: 302 })
})
const request = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test&realmId=123456789'
)

const response = await GET(request)

expect(response.status).toBe(302)
expect(handlerMocks.betterAuthGET).toHaveBeenCalledOnce()
expect(() => getQuickBooksCallbackRealm()).toThrow(/did not include a company identity/)
})

it('delegates a denied callback without requiring a realm', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthGET.mockImplementationOnce(async () => {
expect(() => getQuickBooksCallbackRealm()).toThrow(/did not include a company identity/)
return new NextResponse(null, { status: 302 })
})
const request = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?error=access_denied&state=test'
)

const response = await GET(request)

expect(response.status).toBe(302)
expect(handlerMocks.betterAuthGET).toHaveBeenCalledOnce()
})

it.each([
['missing', 'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test'],
[
'invalid',
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test&realmId=not-a-company',
],
])('rejects a %s callback realm before Better Auth exchanges the code', async (_, url) => {
const request = createMockRequest('GET', undefined, {}, url)

const response = await GET(request)

expect(response.status).toBe(400)
expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled()
})
})

describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
25 changes: 25 additions & 0 deletions apps/sim/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isCredentialGroupOAuthState } from '@/lib/credential-groups/oauth-state'
import { getCredentialGroupStandardOAuthProviderFromProviderId } from '@/lib/credential-groups/providers'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import { normalizeQuickBooksRealmId, withQuickBooksCallbackRealm } from '@/lib/oauth/quickbooks'
import { handleCredentialGroupOAuthCallback } from '@/app/api/credential-groups/oauth-callback'

export const dynamic = 'force-dynamic'
Expand Down Expand Up @@ -106,6 +107,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(createAnonymousSession())
}

if (path === 'oauth2/callback/quickbooks') {
const authorizationCode = request.nextUrl.searchParams.get('code')
if (!authorizationCode) return betterAuthGET(request)

const realmId = request.nextUrl.searchParams.get('realmId')
if (!realmId) {
return NextResponse.json(
{ error: 'QuickBooks callback did not include a company identity.' },
{ status: 400 }
)
}

try {
normalizeQuickBooksRealmId(realmId)
} catch {
return NextResponse.json(
{ error: 'QuickBooks callback included an invalid company identity.' },
{ status: 400 }
)
}

return withQuickBooksCallbackRealm(realmId, () => betterAuthGET(request))
}

return betterAuthGET(request)
})

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/auth/oauth/disconnect/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('OAuth Disconnect API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
dbChainMockFns.where.mockResolvedValue([])
dbChainMockFns.limit.mockResolvedValue([])
})

it('should disconnect provider successfully', async () => {
Expand Down Expand Up @@ -93,7 +93,7 @@ describe('OAuth Disconnect API Route', () => {
session: { id: 'session-1' },
})

dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
dbChainMockFns.limit.mockRejectedValueOnce(new Error('Database error'))

const req = createMockRequest('POST', {
provider: 'google',
Expand Down
89 changes: 89 additions & 0 deletions apps/sim/app/api/auth/oauth/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,74 @@ describe('OAuth Token API Routes', () => {

expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(data).not.toHaveProperty('realmId')

expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).toHaveBeenCalled()
})

it('returns realmId only for QuickBooks credentials', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accountId: 'quickbooks:123456789:intuit-subject-01234567-89ab-4def-8abc-0123456789ab',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'quickbooks',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})

const response = await POST(
createMockRequest('POST', {
credentialId: 'credential-id',
})
)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({
accessToken: 'fresh-token',
realmId: '123456789',
})
})

it('rejects a malformed QuickBooks company identity with reconnect guidance', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accountId: 'malformed',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'quickbooks',
})

const response = await POST(
createMockRequest('POST', {
credentialId: 'credential-id',
})
)
const data = await response.json()

expect(response.status).toBe(401)
expect(data.error).toMatch(/Reconnect the QuickBooks credential/)
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
})

it('should handle workflowId for server-side authentication', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -734,6 +796,33 @@ describe('OAuth Token API Routes', () => {
expect(data).toHaveProperty('error')
})

it('rejects a malformed QuickBooks identity before reporting a missing token', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accountId: 'malformed',
accessToken: null,
refreshToken: 'refresh-token',
providerId: 'quickbooks',
})

const response = await GET(
new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
) as any
)
const data = await response.json()

expect(response.status).toBe(401)
expect(data.error).toMatch(/Reconnect the QuickBooks credential/)
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
})

it('should handle token refresh failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
Expand Down
11 changes: 10 additions & 1 deletion apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token'
import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution'
import {
completeOAuthCredentialToken,
resolveCredentialToken,
validateOAuthCredentialContext,
} from '@/lib/oauth/token-resolution'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { getToolMetadata } from '@/tools/metadata'
Expand Down Expand Up @@ -326,6 +330,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}

const contextValidation = validateOAuthCredentialContext(credential)
if (!contextValidation.ok) {
return NextResponse.json({ error: contextValidation.error }, { status: 401 })
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (!credential.accessToken) {
logger.warn(`[${requestId}] No access token available for credential`)
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
Expand Down
Loading
Loading