Skip to content

Commit c9a51ae

Browse files
fix(credentials): bind shopify completion to oauth state
1 parent 333fdf6 commit c9a51ae

7 files changed

Lines changed: 453 additions & 150 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { hmacSha256Hex } from '@sim/security/hmac'
5+
import { createMockRequest } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockCompleteShopifyOAuthConnection, mockGetSession, mockRequireConfiguredOAuthClient } =
9+
vi.hoisted(() => ({
10+
mockCompleteShopifyOAuthConnection: vi.fn(),
11+
mockGetSession: vi.fn(),
12+
mockRequireConfiguredOAuthClient: vi.fn(),
13+
}))
14+
15+
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
16+
vi.mock('@/lib/core/config/env-capabilities.server', () => ({
17+
requireConfiguredOAuthClient: mockRequireConfiguredOAuthClient,
18+
}))
19+
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))
20+
vi.mock('@/lib/oauth/shopify', () => ({
21+
completeShopifyOAuthConnection: mockCompleteShopifyOAuthConnection,
22+
}))
23+
24+
import { createShopifyOAuthState } from '@/lib/oauth/shopify-state'
25+
import { GET } from '@/app/api/auth/oauth2/callback/shopify/route'
26+
27+
const CLIENT_SECRET = 'shopify-client-secret'
28+
const SHOP_DOMAIN = 'example.myshopify.com'
29+
30+
function callbackRequest(state: string) {
31+
const searchParams = new URLSearchParams({
32+
code: 'authorization-code',
33+
shop: SHOP_DOMAIN,
34+
state,
35+
})
36+
const message = [...searchParams.entries()]
37+
.sort(([left], [right]) => left.localeCompare(right))
38+
.map(([key, value]) => `${key}=${value}`)
39+
.join('&')
40+
searchParams.set('hmac', hmacSha256Hex(message, CLIENT_SECRET))
41+
42+
return createMockRequest(
43+
'GET',
44+
undefined,
45+
{
46+
cookie:
47+
'shopify_credential_draft_id=draft-from-shared-cookie; shopify_return_url=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected',
48+
},
49+
`https://sim.test/api/auth/oauth2/callback/shopify?${searchParams.toString()}`
50+
)
51+
}
52+
53+
describe('Shopify OAuth callback', () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
57+
mockRequireConfiguredOAuthClient.mockReturnValue({
58+
values: {
59+
SHOPIFY_CLIENT_ID: 'shopify-client-id',
60+
SHOPIFY_CLIENT_SECRET: CLIENT_SECRET,
61+
},
62+
})
63+
mockCompleteShopifyOAuthConnection.mockResolvedValue(undefined)
64+
vi.stubGlobal(
65+
'fetch',
66+
vi.fn().mockResolvedValue(
67+
new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), {
68+
status: 200,
69+
headers: { 'Content-Type': 'application/json' },
70+
})
71+
)
72+
)
73+
})
74+
75+
it('completes the credential draft carried by signed state instead of a shared cookie', async () => {
76+
const state = createShopifyOAuthState({
77+
userId: 'user-1',
78+
shopDomain: SHOP_DOMAIN,
79+
draftId: 'draft-from-state',
80+
clientSecret: CLIENT_SECRET,
81+
})
82+
83+
const response = await GET(callbackRequest(state))
84+
85+
expect(mockCompleteShopifyOAuthConnection).toHaveBeenCalledWith({
86+
accessToken: 'shopify-token',
87+
shopDomain: SHOP_DOMAIN,
88+
scope: 'read_products',
89+
userId: 'user-1',
90+
draftId: 'draft-from-state',
91+
signal: expect.any(AbortSignal),
92+
})
93+
expect(response.headers.get('location')).toBe(
94+
'https://sim.test/oauth/credential-connected?result=connected&shopify_connected=true'
95+
)
96+
})
97+
})

apps/sim/app/api/auth/oauth2/callback/shopify/route.ts

Lines changed: 40 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,26 @@ import { getSession } from '@/lib/auth'
1010
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
1111
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
1212
import { getBaseUrl } from '@/lib/core/utils/urls'
13+
import { isSameOrigin } from '@/lib/core/utils/validation'
1314
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
15+
import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify'
16+
import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state'
1417

1518
const logger = createLogger('ShopifyCallback')
1619

1720
export const dynamic = 'force-dynamic'
1821

22+
function clearShopifyOAuthCookies(response: NextResponse): NextResponse {
23+
response.cookies.delete('shopify_oauth_state')
24+
response.cookies.delete('shopify_shop_domain')
25+
response.cookies.delete('shopify_credential_draft_id')
26+
response.cookies.delete('shopify_pending_token')
27+
response.cookies.delete('shopify_pending_shop')
28+
response.cookies.delete('shopify_pending_scope')
29+
response.cookies.delete('shopify_return_url')
30+
return response
31+
}
32+
1933
/**
2034
* Validates the HMAC signature from Shopify to ensure the request is authentic
2135
* @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
@@ -59,9 +73,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5973
shop: searchParams.get('shop') || undefined,
6074
})
6175

62-
const storedState = request.cookies.get('shopify_oauth_state')?.value
63-
const storedShop = request.cookies.get('shopify_shop_domain')?.value
64-
6576
const {
6677
values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret },
6778
} = requireConfiguredOAuthClient('shopify')
@@ -71,8 +82,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7182
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`)
7283
}
7384

74-
if (!state || state !== storedState) {
75-
logger.error('State mismatch in Shopify OAuth callback')
85+
if (!state) {
86+
logger.error('Missing state in Shopify OAuth callback')
7687
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`)
7788
}
7889

@@ -81,7 +92,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
8192
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`)
8293
}
8394

84-
const shopDomain = shop || storedShop
95+
const shopDomain = shop
8596
if (!shopDomain) {
8697
logger.error('No shop domain available')
8798
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`)
@@ -92,6 +103,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
92103
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`)
93104
}
94105

106+
const { draftId } = parseShopifyOAuthState({
107+
state,
108+
userId: session.user.id,
109+
shopDomain,
110+
clientSecret,
111+
})
112+
95113
const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, {
96114
method: 'POST',
97115
headers: {
@@ -127,44 +145,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
127145
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`)
128146
}
129147

130-
const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`)
131-
132-
const response = NextResponse.redirect(storeUrl)
133-
134-
response.cookies.set('shopify_pending_token', accessToken, {
135-
httpOnly: true,
136-
secure: process.env.NODE_ENV === 'production',
137-
sameSite: 'lax',
138-
maxAge: 60,
139-
path: '/',
140-
})
141-
142-
response.cookies.set('shopify_pending_shop', shopDomain, {
143-
httpOnly: true,
144-
secure: process.env.NODE_ENV === 'production',
145-
sameSite: 'lax',
146-
maxAge: 60,
147-
path: '/',
148-
})
149-
150-
response.cookies.set('shopify_pending_scope', scope || '', {
151-
httpOnly: true,
152-
secure: process.env.NODE_ENV === 'production',
153-
sameSite: 'lax',
154-
maxAge: 60,
155-
path: '/',
148+
await completeShopifyOAuthConnection({
149+
accessToken,
150+
shopDomain,
151+
scope,
152+
userId: session.user.id,
153+
draftId,
154+
signal: request.signal,
156155
})
157156

158-
response.cookies.delete('shopify_oauth_state')
159-
response.cookies.delete('shopify_shop_domain')
157+
const returnUrlCookie = request.cookies.get('shopify_return_url')?.value
158+
const redirectUrl =
159+
returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace`
160+
const finalUrl = new URL(redirectUrl)
161+
finalUrl.searchParams.set('shopify_connected', 'true')
160162

161-
return response
163+
return clearShopifyOAuthCookies(NextResponse.redirect(finalUrl))
162164
} catch (error) {
163165
logger.error('Error in Shopify OAuth callback:', error)
164166
const errorCode =
165167
error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth'
166168
? 'shopify_config_error'
167169
: 'shopify_callback_error'
168-
return NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`)
170+
return clearShopifyOAuthCookies(
171+
NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`)
172+
)
169173
}
170174
})

apps/sim/app/api/auth/oauth2/shopify/store/route.ts

Lines changed: 7 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import { db } from '@sim/db'
2-
import { account } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
52
import { type NextRequest, NextResponse } from 'next/server'
63
import {
74
shopifyShopDomainSchema,
@@ -11,9 +8,7 @@ import { getSession } from '@/lib/auth'
118
import { getBaseUrl } from '@/lib/core/utils/urls'
129
import { isSameOrigin } from '@/lib/core/utils/validation'
1310
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14-
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
15-
import { safeAccountInsert } from '@/lib/oauth/credential-service'
16-
import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants'
11+
import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify'
1712

1813
const logger = createLogger('ShopifyStore')
1914

@@ -48,85 +43,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4843
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`)
4944
}
5045

51-
const shopResponse = await fetch(
52-
`https://${shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`,
53-
{
54-
headers: {
55-
'X-Shopify-Access-Token': accessToken,
56-
'Content-Type': 'application/json',
57-
},
58-
}
59-
)
60-
61-
if (!shopResponse.ok) {
62-
const errorText = await shopResponse.text()
63-
logger.error('Invalid Shopify token', {
64-
status: shopResponse.status,
65-
error: errorText,
66-
})
67-
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`)
68-
}
69-
70-
const shopData = await shopResponse.json()
71-
const shopInfo = shopData.shop
72-
const stableAccountId = shopInfo.id?.toString() || shopDomain
73-
74-
const existing = await db.query.account.findFirst({
75-
where: and(
76-
eq(account.userId, session.user.id),
77-
eq(account.providerId, 'shopify'),
78-
eq(account.accountId, stableAccountId)
79-
),
80-
})
81-
82-
const now = new Date()
83-
84-
const accountData = {
85-
accessToken: accessToken,
86-
accountId: stableAccountId,
87-
scope: scope || '',
88-
updatedAt: now,
89-
idToken: shopDomain,
90-
}
91-
92-
if (existing) {
93-
await db.update(account).set(accountData).where(eq(account.id, existing.id))
94-
logger.info('Updated existing Shopify account', { accountId: existing.id })
95-
} else {
96-
await safeAccountInsert(
97-
{
98-
id: `shopify_${session.user.id}_${Date.now()}`,
99-
userId: session.user.id,
100-
providerId: 'shopify',
101-
accountId: accountData.accountId,
102-
accessToken: accountData.accessToken,
103-
scope: accountData.scope,
104-
idToken: accountData.idToken,
105-
createdAt: now,
106-
updatedAt: now,
107-
},
108-
{ provider: 'Shopify', identifier: shopDomain }
109-
)
110-
}
111-
112-
const persisted =
113-
existing ??
114-
(await db.query.account.findFirst({
115-
where: and(
116-
eq(account.userId, session.user.id),
117-
eq(account.providerId, 'shopify'),
118-
eq(account.accountId, stableAccountId)
119-
),
120-
}))
121-
122-
if (!persisted) {
123-
throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`)
124-
}
125-
await processCredentialDraft({
126-
draftId,
46+
await completeShopifyOAuthConnection({
47+
accessToken,
48+
shopDomain,
49+
scope,
12750
userId: session.user.id,
128-
providerId: 'shopify',
129-
accountId: persisted.id,
51+
draftId,
52+
signal: request.signal,
13053
})
13154

13255
const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace`

0 commit comments

Comments
 (0)