Skip to content

Commit 3a89100

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): use sandbox OAuth profile endpoint
1 parent cf4da41 commit 3a89100

3 files changed

Lines changed: 243 additions & 43 deletions

File tree

apps/sim/lib/auth/auth.ts

Lines changed: 5 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
getMicrosoftRefreshTokenExpiry,
103103
isMicrosoftProvider,
104104
} from '@/lib/oauth/microsoft'
105+
import { fetchQuickBooksUserInfo, mapQuickBooksUserInfo } from '@/lib/oauth/quickbooks'
105106
import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack'
106107
import { clearDeadFlag } from '@/lib/oauth/terminal-errors'
107108
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
@@ -1922,50 +1923,11 @@ export const auth = betterAuth({
19221923
getUserInfo: async (tokens) => {
19231924
try {
19241925
logger.info('Fetching QuickBooks user profile')
1925-
1926-
const response = await fetch(
1927-
'https://accounts.platform.intuit.com/v1/openid_connect/userinfo',
1928-
{
1929-
headers: {
1930-
Authorization: `Bearer ${tokens.accessToken}`,
1931-
},
1932-
}
1926+
const profile = await fetchQuickBooksUserInfo(
1927+
tokens.accessToken,
1928+
env.NODE_ENV !== 'production'
19331929
)
1934-
1935-
if (!response.ok) {
1936-
await response.text().catch(() => {})
1937-
logger.error('Failed to fetch QuickBooks user info', {
1938-
status: response.status,
1939-
statusText: response.statusText,
1940-
})
1941-
throw new Error('Failed to fetch user info')
1942-
}
1943-
1944-
const profile = (await response.json()) as {
1945-
sub?: string
1946-
given_name?: string
1947-
givenName?: string
1948-
family_name?: string
1949-
familyName?: string
1950-
email?: string
1951-
email_verified?: boolean
1952-
emailVerified?: boolean
1953-
}
1954-
const subject = profile.sub || 'quickbooks-user'
1955-
const givenName = profile.given_name ?? profile.givenName ?? ''
1956-
const familyName = profile.family_name ?? profile.familyName ?? ''
1957-
const name = `${givenName} ${familyName}`.trim() || profile.email || 'QuickBooks User'
1958-
1959-
return {
1960-
id: `${subject}-${generateId()}`,
1961-
name,
1962-
email: profile.email || `${subject}@quickbooks.user`,
1963-
emailVerified:
1964-
profile.email_verified ?? profile.emailVerified ?? Boolean(profile.email),
1965-
image: undefined,
1966-
createdAt: new Date(),
1967-
updatedAt: new Date(),
1968-
}
1930+
return mapQuickBooksUserInfo(profile)
19691931
} catch (error) {
19701932
logger.error('Error in QuickBooks getUserInfo:', { error })
19711933
return null
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import {
6+
fetchQuickBooksUserInfo,
7+
getQuickBooksUserInfoEndpoints,
8+
mapQuickBooksUserInfo,
9+
} from '@/lib/oauth/quickbooks'
10+
11+
describe('getQuickBooksUserInfoEndpoints', () => {
12+
it('prefers the sandbox endpoint for local development', () => {
13+
expect(getQuickBooksUserInfoEndpoints(true)).toEqual([
14+
'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo',
15+
'https://accounts.platform.intuit.com/v1/openid_connect/userinfo',
16+
])
17+
})
18+
19+
it('prefers the production endpoint for production deployments', () => {
20+
expect(getQuickBooksUserInfoEndpoints(false)).toEqual([
21+
'https://accounts.platform.intuit.com/v1/openid_connect/userinfo',
22+
'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo',
23+
])
24+
})
25+
})
26+
27+
describe('fetchQuickBooksUserInfo', () => {
28+
afterEach(() => {
29+
vi.unstubAllGlobals()
30+
})
31+
32+
it('uses the sandbox user-info endpoint and required headers locally', async () => {
33+
const fetchMock = vi.fn().mockResolvedValue(
34+
new Response(
35+
JSON.stringify({
36+
sub: 'intuit-user-1',
37+
email: 'user@example.com',
38+
emailVerified: true,
39+
})
40+
)
41+
)
42+
vi.stubGlobal('fetch', fetchMock)
43+
44+
await expect(fetchQuickBooksUserInfo('access-token', true)).resolves.toMatchObject({
45+
sub: 'intuit-user-1',
46+
})
47+
expect(fetchMock).toHaveBeenCalledOnce()
48+
expect(fetchMock).toHaveBeenCalledWith(
49+
'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo',
50+
{
51+
headers: {
52+
Accept: 'application/json',
53+
Authorization: 'Bearer access-token',
54+
},
55+
}
56+
)
57+
})
58+
59+
it('falls back to production when the sandbox endpoint rejects the token', async () => {
60+
const fetchMock = vi
61+
.fn()
62+
.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }))
63+
.mockResolvedValueOnce(
64+
new Response(
65+
JSON.stringify({
66+
sub: 'intuit-user-2',
67+
email: 'user@example.com',
68+
emailVerified: true,
69+
})
70+
)
71+
)
72+
vi.stubGlobal('fetch', fetchMock)
73+
74+
await expect(fetchQuickBooksUserInfo('access-token', true)).resolves.toMatchObject({
75+
sub: 'intuit-user-2',
76+
})
77+
expect(fetchMock).toHaveBeenCalledTimes(2)
78+
expect(fetchMock.mock.calls[1]?.[0]).toBe(
79+
'https://accounts.platform.intuit.com/v1/openid_connect/userinfo'
80+
)
81+
})
82+
83+
it('fails with endpoint statuses when neither environment accepts the token', async () => {
84+
const fetchMock = vi
85+
.fn()
86+
.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }))
87+
.mockResolvedValueOnce(new Response('Forbidden', { status: 403 }))
88+
vi.stubGlobal('fetch', fetchMock)
89+
90+
await expect(fetchQuickBooksUserInfo('access-token', true)).rejects.toThrow(
91+
'sandbox-accounts.platform.intuit.com: HTTP 401; accounts.platform.intuit.com: HTTP 403'
92+
)
93+
})
94+
})
95+
96+
describe('mapQuickBooksUserInfo', () => {
97+
it('maps Intuit camel-case profile fields to a stable OAuth identity', () => {
98+
expect(
99+
mapQuickBooksUserInfo({
100+
sub: 'intuit-user-3',
101+
givenName: 'Ada',
102+
familyName: 'Lovelace',
103+
email: 'ada@example.com',
104+
emailVerified: true,
105+
})
106+
).toMatchObject({
107+
id: 'intuit-user-3',
108+
name: 'Ada Lovelace',
109+
email: 'ada@example.com',
110+
emailVerified: true,
111+
})
112+
})
113+
114+
it('provides non-empty fallback fields when optional profile scopes omit them', () => {
115+
expect(mapQuickBooksUserInfo({ sub: 'intuit-user-4' })).toMatchObject({
116+
id: 'intuit-user-4',
117+
name: 'QuickBooks User',
118+
email: 'intuit-user-4@quickbooks.user',
119+
emailVerified: false,
120+
})
121+
})
122+
123+
it('rejects a profile without Intuit subject identity', () => {
124+
expect(() => mapQuickBooksUserInfo({ email: 'user@example.com' })).toThrow(
125+
'did not include a subject'
126+
)
127+
})
128+
})

apps/sim/lib/oauth/quickbooks.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import {
4+
readResponseJsonWithLimit,
5+
readResponseTextWithLimit,
6+
} from '@/lib/core/utils/stream-limits'
7+
8+
const logger = createLogger('QuickBooksOAuth')
9+
10+
const QUICKBOOKS_USER_INFO_URLS = {
11+
production: 'https://accounts.platform.intuit.com/v1/openid_connect/userinfo',
12+
sandbox: 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo',
13+
} as const
14+
15+
const MAX_USER_INFO_RESPONSE_BYTES = 1024 * 1024
16+
17+
export interface QuickBooksUserInfo {
18+
sub?: string
19+
given_name?: string
20+
givenName?: string
21+
family_name?: string
22+
familyName?: string
23+
email?: string
24+
email_verified?: boolean
25+
emailVerified?: boolean
26+
}
27+
28+
export function getQuickBooksUserInfoEndpoints(preferSandbox: boolean): string[] {
29+
return preferSandbox
30+
? [QUICKBOOKS_USER_INFO_URLS.sandbox, QUICKBOOKS_USER_INFO_URLS.production]
31+
: [QUICKBOOKS_USER_INFO_URLS.production, QUICKBOOKS_USER_INFO_URLS.sandbox]
32+
}
33+
34+
export async function fetchQuickBooksUserInfo(
35+
accessToken: string | undefined,
36+
preferSandbox: boolean
37+
): Promise<QuickBooksUserInfo> {
38+
if (!accessToken) {
39+
throw new Error('QuickBooks OAuth token response did not include an access token')
40+
}
41+
42+
const failures: string[] = []
43+
44+
for (const endpoint of getQuickBooksUserInfoEndpoints(preferSandbox)) {
45+
try {
46+
const response = await fetch(endpoint, {
47+
headers: {
48+
Accept: 'application/json',
49+
Authorization: `Bearer ${accessToken}`,
50+
},
51+
})
52+
53+
if (!response.ok) {
54+
await readResponseTextWithLimit(response, {
55+
maxBytes: MAX_USER_INFO_RESPONSE_BYTES,
56+
label: 'QuickBooks user info error response',
57+
}).catch(() => {})
58+
failures.push(`${new URL(endpoint).hostname}: HTTP ${response.status}`)
59+
logger.warn('QuickBooks user info endpoint rejected the access token', {
60+
endpointHost: new URL(endpoint).hostname,
61+
status: response.status,
62+
})
63+
continue
64+
}
65+
66+
const profile = await readResponseJsonWithLimit<QuickBooksUserInfo>(response, {
67+
maxBytes: MAX_USER_INFO_RESPONSE_BYTES,
68+
label: 'QuickBooks user info response',
69+
})
70+
71+
if (!profile.sub) {
72+
failures.push(`${new URL(endpoint).hostname}: missing sub claim`)
73+
logger.warn('QuickBooks user info response did not include a subject', {
74+
endpointHost: new URL(endpoint).hostname,
75+
})
76+
continue
77+
}
78+
79+
return profile
80+
} catch (error) {
81+
failures.push(`${new URL(endpoint).hostname}: ${getErrorMessage(error)}`)
82+
logger.warn('QuickBooks user info request failed', {
83+
endpointHost: new URL(endpoint).hostname,
84+
error: getErrorMessage(error),
85+
})
86+
}
87+
}
88+
89+
throw new Error(`QuickBooks user info request failed (${failures.join('; ')})`)
90+
}
91+
92+
export function mapQuickBooksUserInfo(profile: QuickBooksUserInfo) {
93+
if (!profile.sub) {
94+
throw new Error('QuickBooks user info response did not include a subject')
95+
}
96+
97+
const givenName = profile.given_name ?? profile.givenName ?? ''
98+
const familyName = profile.family_name ?? profile.familyName ?? ''
99+
const name = `${givenName} ${familyName}`.trim() || profile.email || 'QuickBooks User'
100+
101+
return {
102+
id: profile.sub,
103+
name,
104+
email: profile.email || `${profile.sub}@quickbooks.user`,
105+
emailVerified: profile.email_verified ?? profile.emailVerified ?? Boolean(profile.email),
106+
image: undefined,
107+
createdAt: new Date(),
108+
updatedAt: new Date(),
109+
}
110+
}

0 commit comments

Comments
 (0)