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
6 changes: 5 additions & 1 deletion apps/sim/app/(auth)/auth-redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ export function isOAuthAuthorizationCallback(callbackUrl: string, origin: string
if (!callbackUrl) return false
try {
const callback = new URL(callbackUrl, origin)
return callback.origin === origin && callback.pathname === '/api/auth/oauth2/authorize'
return (
callback.origin === origin &&
(callback.pathname === '/api/auth/oauth2/authorize' ||
callback.pathname === '/oauth/shopify/connect')
)
} catch {
return false
}
Expand Down
38 changes: 38 additions & 0 deletions apps/sim/app/api/admin/shopify/privacy/[requestId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
readShopifyPrivacyContract,
reviewShopifyPrivacyContract,
type ShopifyPrivacyDetailResponse,
shopifyPrivacyDetailResponseSchema,
} from '@/lib/api/contracts/shopify-privacy'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { readShopifyPrivacyRequest } from '@/lib/shopify/privacy/application/read-requests'
import { reviewShopifyPrivacyRequest } from '@/lib/shopify/privacy/application/review-request'

export const GET = defineInternalJsonRoute({
contract: readShopifyPrivacyContract,
auth: internalSessionAuth,
operation: readShopifyPrivacyRequest.operation,
rateLimit: internalRateLimits.user({ bucketName: 'shopify-privacy-admin' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, query }) => ({ ...params, ...query }),
useCase: readShopifyPrivacyRequest,
present: (result): ShopifyPrivacyDetailResponse =>
shopifyPrivacyDetailResponseSchema.parse(result),
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
})

export const POST = defineInternalJsonRoute({
contract: reviewShopifyPrivacyContract,
auth: internalSessionAuth,
operation: reviewShopifyPrivacyRequest.operation,
rateLimit: internalRateLimits.user({ bucketName: 'shopify-privacy-admin' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ ...params, ...body }),
useCase: reviewShopifyPrivacyRequest,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
})
24 changes: 24 additions & 0 deletions apps/sim/app/api/admin/shopify/privacy/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
listShopifyPrivacyContract,
type ShopifyPrivacyListResponse,
shopifyPrivacyListResponseSchema,
} from '@/lib/api/contracts/shopify-privacy'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listShopifyPrivacyRequests } from '@/lib/shopify/privacy/application/read-requests'

export const GET = defineInternalJsonRoute({
contract: listShopifyPrivacyContract,
auth: internalSessionAuth,
operation: listShopifyPrivacyRequests.operation,
rateLimit: internalRateLimits.user({ bucketName: 'shopify-privacy-admin' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ query }) => query,
useCase: listShopifyPrivacyRequests,
present: (result): ShopifyPrivacyListResponse => shopifyPrivacyListResponseSchema.parse(result),
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
})
86 changes: 46 additions & 40 deletions apps/sim/app/api/auth/oauth2/callback/shopify/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { EnvCapabilityConfigurationError } from '@sim/deployment-config/env-capabilities'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { type NextRequest, NextResponse } from 'next/server'
import {
shopifyCallbackQuerySchema,
Expand All @@ -14,6 +12,13 @@ import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify'
import { completeShopifyInstallHandoff } from '@/lib/oauth/shopify-handoff'
import {
parseShopifyInstallState,
SHOPIFY_INSTALL_TTL_MS,
shopifyInstallCookieName,
validateShopifyQueryHmac,
} from '@/lib/oauth/shopify-install-protocol'
import { ShopifyOAuthError } from '@/lib/oauth/shopify-installation'
import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state'

Expand All @@ -32,40 +37,16 @@ function clearShopifyOAuthCookies(response: NextResponse): NextResponse {
return response
}

/**
* Validates the HMAC signature from Shopify to ensure the request is authentic
* @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
*/
function validateHmac(searchParams: URLSearchParams, clientSecret: string): boolean {
const hmac = searchParams.get('hmac')
if (!hmac) {
return false
}

const params: Record<string, string> = {}
searchParams.forEach((value, key) => {
if (key !== 'hmac') {
params[key] = value
}
})

const message = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&')

const generatedHmac = hmacSha256Hex(message, clientSecret)

return safeCompare(hmac, generatedHmac)
}

export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
const installationFlow =
request.nextUrl.searchParams.get('state')?.startsWith('install.') ?? false
const errorPath = installationFlow ? '/oauth/shopify/connect' : APP_ENTRY_PATH

try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`)
const session = installationFlow ? null : await getSession()
if (!installationFlow && !session?.user?.id) {
return NextResponse.redirect(`${baseUrl}${errorPath}?error=unauthorized`)
}

const { searchParams } = request.nextUrl
Expand All @@ -79,31 +60,56 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
values: { SHOPIFY_CLIENT_SECRET: clientSecret },
} = requireConfiguredOAuthClient('shopify')

if (!validateHmac(searchParams, clientSecret)) {
if (!validateShopifyQueryHmac(searchParams, clientSecret)) {
logger.error('HMAC validation failed in Shopify OAuth callback')
return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_hmac_invalid`)
return NextResponse.redirect(`${baseUrl}${errorPath}?error=shopify_hmac_invalid`)
}

if (!state) {
logger.error('Missing state in Shopify OAuth callback')
return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_state_mismatch`)
return NextResponse.redirect(`${baseUrl}${errorPath}?error=shopify_state_mismatch`)
}

if (!code) {
logger.error('No code received from Shopify')
return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_code`)
return NextResponse.redirect(`${baseUrl}${errorPath}?error=shopify_no_code`)
}

const shopDomain = shop
const shopDomain = installationFlow ? shop?.toLowerCase() : shop
if (!shopDomain) {
logger.error('No shop domain available')
return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_shop`)
return NextResponse.redirect(`${baseUrl}${errorPath}?error=shopify_no_shop`)
}

if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
logger.error('Invalid shop domain format:', { shopDomain })
return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_invalid_shop`)
return NextResponse.redirect(`${baseUrl}${errorPath}?error=shopify_invalid_shop`)
}

if (installationFlow) {
const attemptId = parseShopifyInstallState(state, shopDomain, clientSecret)
const browserProof = request.cookies.get(shopifyInstallCookieName(attemptId))?.value ?? ''
await completeShopifyInstallHandoff({
attemptId,
browserProof,
shopDomain,
code,
signal: request.signal,
})
const destination = new URL('/oauth/shopify/connect', baseUrl)
destination.searchParams.set('attempt', attemptId)
const response = NextResponse.redirect(destination)
response.headers.set('Cache-Control', 'no-store')
response.cookies.set(shopifyInstallCookieName(attemptId), browserProof, {
httpOnly: true,
secure: new URL(baseUrl).protocol === 'https:',
sameSite: 'lax',
path: '/',
maxAge: SHOPIFY_INSTALL_TTL_MS / 1000,
})
return response
}
if (!session?.user?.id) throw new Error('Shopify connection requires a Sim user')

const { draftId, returnUrl } = parseShopifyOAuthState({
state,
Expand Down Expand Up @@ -139,7 +145,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
? error.callbackError
: 'shopify_callback_error'
return clearShopifyOAuthCookies(
NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`)
NextResponse.redirect(`${baseUrl}${errorPath}?error=${errorCode}`)
)
}
})
28 changes: 28 additions & 0 deletions apps/sim/app/api/auth/shopify/complete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { completeShopifyInstallContract } from '@/lib/api/contracts/shopify-install'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { completeShopifyInstall } from '@/lib/credentials/application/complete-shopify-install'
import { credentialOperations } from '@/lib/credentials/application/operations'
import { shopifyInstallCookieName } from '@/lib/oauth/shopify-install-protocol'

export const POST = defineInternalJsonRoute({
contract: completeShopifyInstallContract,
auth: internalSessionAuth,
operation: credentialOperations.completeConnection,
rateLimit: internalRateLimits.user({ bucketName: 'shopify-install-claim' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ body }, { request }) => ({
...body,
browserProof: request.cookies.get(shopifyInstallCookieName(body.attemptId))?.value ?? '',
}),
useCase: completeShopifyInstall,
present: (result) => ({ credentialId: result.credentialId, workspaceId: result.workspaceId }),
responseHeaders: ({ input }) => ({
'Cache-Control': 'private, no-store',
'Set-Cookie': `${shopifyInstallCookieName(input.attemptId)}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
}),
})
67 changes: 67 additions & 0 deletions apps/sim/app/api/auth/shopify/install/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createLogger } from '@sim/logger'
import { generateRandomHex } from '@sim/utils/random'
import { type NextRequest, NextResponse } from 'next/server'
import { shopifyInstallContract } from '@/lib/api/contracts/shopify-install'
import { parseRequest } from '@/lib/api/server'
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createShopifyInstallAttempt } from '@/lib/oauth/shopify-handoff'
import {
createShopifyInstallState,
SHOPIFY_INSTALL_COOKIE_PREFIX,
SHOPIFY_INSTALL_TTL_MS,
shopifyInstallCookieName,
validateShopifyQueryHmac,
} from '@/lib/oauth/shopify-install-protocol'
import { getScopesForService } from '@/lib/oauth/utils'

const logger = createLogger('ShopifyInstall')

/** Shopify launch protocol: initiate OAuth before rendering Sim login or workspace selection. */
export const GET = withRouteHandler(async (request: NextRequest) => {
if (request.method !== 'GET') return new NextResponse(null, { status: 405 })
const baseUrl = getBaseUrl()
try {
request.signal.throwIfAborted()
const { values } = requireConfiguredOAuthClient('shopify')
const query = request.nextUrl.searchParams
if (!validateShopifyQueryHmac(query, values.SHOPIFY_CLIENT_SECRET))
throw new Error('Invalid Shopify launch signature')
const parsedRequest = await parseRequest(shopifyInstallContract, request, {})
if (!parsedRequest.success) throw new Error('Invalid Shopify launch parameters')
const parsed = parsedRequest.data.query
if (Math.abs(Date.now() - Number(parsed.timestamp) * 1000) > 300_000)
throw new Error('Expired Shopify launch')
if (
request.cookies
.getAll()
.filter((cookie) => cookie.name.startsWith(SHOPIFY_INSTALL_COOKIE_PREFIX)).length >= 5
)
throw new Error('Too many pending browser installations')
const shopDomain = parsed.shop.toLowerCase()
const browserProof = generateRandomHex(64)
const attemptId = await createShopifyInstallAttempt(shopDomain, browserProof)
const authorization = new URL(`https://${shopDomain}/admin/oauth/authorize`)
authorization.search = new URLSearchParams({
client_id: values.SHOPIFY_CLIENT_ID,
scope: getScopesForService('shopify').join(','),
redirect_uri: `${baseUrl}/api/auth/oauth2/callback/shopify`,
state: createShopifyInstallState(attemptId, shopDomain, values.SHOPIFY_CLIENT_SECRET),
}).toString()
const response = NextResponse.redirect(authorization)
response.headers.set('Cache-Control', 'no-store')
response.cookies.set(shopifyInstallCookieName(attemptId), browserProof, {
httpOnly: true,
secure: new URL(baseUrl).protocol === 'https:',
sameSite: 'lax',
path: '/',
maxAge: SHOPIFY_INSTALL_TTL_MS / 1000,
})
return response
} catch (error) {
if (request.signal.aborted) throw error
logger.warn('Shopify installation launch rejected')
return NextResponse.redirect(`${baseUrl}/oauth/shopify/connect?error=shopify_install_invalid`)
}
})
2 changes: 2 additions & 0 deletions apps/sim/app/api/files/uploads/purposes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ function requireSessionScope(value: string | null, label = 'scope'): string {

async function principalUserId(principal: Principal, workspaceId?: string): Promise<string> {
switch (principal.kind) {
case 'shopify_privacy':
throw new UploadSessionError('forbidden', 'Privacy webhook authority cannot create uploads')
case 'slack_app':
case 'slack_installation':
throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads')
Expand Down
Loading
Loading